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.
This commit is contained in:
KeelrLeek committed 2026-07-22 17:57:00 -05:00
commit a667b4b28e
161 files changed
+53170

No files matched your search

+15
View File
@@ -0,0 +1,15 @@
[package]
name = "metadata"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
[dependencies]
futures = "0.3"
image.workspace = true
reqwest = { version = "0.12", features = ["json"] }
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
thiserror.workspace = true
log.workspace = true
@@ -0,0 +1,123 @@
//! Latency harness for the album-description path (`album_info_for_artist_id`).
//!
//! This is the description counterpart to `discography_audit`: it measures the
//! call the artist page's expander and background hint harvest both make, so
//! optimization work has a before/after number instead of an impression.
//!
//! Usage:
//! cargo run -p metadata --example album_info_bench -- "Artist" "Title" "Title" ...
//!
//! Reports, in order:
//! 1. COLD SEQUENTIAL — one fresh client, titles one at a time. This is what
//! the frontend harvest does today (`for (const title of titles) await`),
//! so its total is the wall-clock the user waits for the tabs to settle.
//! 2. WARM SEQUENTIAL — same client again, exercising the per-source caches.
//! 3. COLD CONCURRENT — a second fresh client, all titles at once. The gap
//! against (1) is the ceiling available from parallelizing the harvest.
use metadata::MetadataClient;
use std::sync::Arc;
use std::time::Instant;
#[tokio::main]
async fn main() {
let mut args = std::env::args().skip(1);
let Some(artist) = args.next() else {
eprintln!("usage: album_info_bench -- \"Artist\" \"Title\" [\"Title\" ...]");
return;
};
let titles: Vec<String> = args.collect();
if titles.is_empty() {
eprintln!("give at least one album title");
return;
}
let client = Arc::new(MetadataClient::new());
let search = client.catalog_search(&artist).await;
let artist_id = search
.artists
.iter()
.find(|a| normalize(&a.name) == normalize(&artist))
.map(|a| a.id);
println!(
"artist {artist:?} -> id {artist_id:?}, {} titles\n",
titles.len()
);
let cold = run_sequential(&client, artist_id, &artist, &titles, "COLD SEQUENTIAL").await;
let warm = run_sequential(&client, artist_id, &artist, &titles, "WARM SEQUENTIAL").await;
// A second client so the concurrent run is genuinely cold; sharing the
// first one would measure the cache, not the network path.
let fresh = Arc::new(MetadataClient::new());
let started = Instant::now();
let mut tasks = Vec::new();
for title in &titles {
let fresh = Arc::clone(&fresh);
let artist = artist.clone();
let title = title.clone();
tasks.push(tokio::spawn(async move {
let at = Instant::now();
let info = fresh
.album_info_for_artist_id(artist_id, &artist, &title)
.await;
(title, at.elapsed().as_millis(), info.type_hint)
}));
}
let mut rows = Vec::new();
for task in tasks {
if let Ok(row) = task.await {
rows.push(row);
}
}
let concurrent = started.elapsed().as_millis();
println!("== COLD CONCURRENT ==");
for (title, ms, hint) in &rows {
println!(" {ms:>6} ms {title} hint={hint:?}");
}
println!(" total {concurrent} ms\n");
println!("== SUMMARY ==");
println!(" cold sequential : {cold:>6} ms <- today's harvest wall-clock");
println!(" warm sequential : {warm:>6} ms <- per-source cache hit");
println!(" cold concurrent : {concurrent:>6} ms <- ceiling from fan-out");
if concurrent > 0 {
println!(
" speedup : {:.1}x",
cold as f64 / concurrent as f64
);
}
}
async fn run_sequential(
client: &Arc<MetadataClient>,
artist_id: Option<u64>,
artist: &str,
titles: &[String],
label: &str,
) -> u128 {
println!("== {label} ==");
let started = Instant::now();
for title in titles {
let at = Instant::now();
let info = client
.album_info_for_artist_id(artist_id, artist, title)
.await;
println!(
" {:>6} ms {title} hint={:?} source={:?}",
at.elapsed().as_millis(),
info.type_hint,
info.source,
);
}
let total = started.elapsed().as_millis();
println!(" total {total} ms\n");
total
}
fn normalize(s: &str) -> String {
s.to_lowercase()
.chars()
.filter(|c| c.is_alphanumeric())
.collect()
}
@@ -0,0 +1,44 @@
//! Live probe for identity-scoped release descriptions + type hints.
//!
//! Usage: cargo run -p metadata --example album_info_probe -- "Artist|Title" ...
use metadata::MetadataClient;
use std::sync::Arc;
#[tokio::main]
async fn main() {
let client = Arc::new(MetadataClient::new());
let requests: Vec<String> = std::env::args().skip(1).collect();
for request in requests {
let Some((artist, title)) = request.split_once('|') else {
eprintln!("expected Artist|Title, got {request}");
continue;
};
let search = client.catalog_search(artist).await;
let id = search
.artists
.iter()
.find(|a| normalize(&a.name) == normalize(artist))
.map(|a| a.id);
let info = client.album_info_for_artist_id(id, artist, title).await;
println!(
"{} | {} (id {:?})\n source={:?} hint={:?}\n desc={}\n",
artist,
title,
id,
info.source,
info.type_hint,
info.description
.as_deref()
.map(|d| d.chars().take(140).collect::<String>())
.unwrap_or_else(|| "<none>".into()),
);
}
}
fn normalize(s: &str) -> String {
s.to_lowercase()
.chars()
.filter(|c| c.is_alphanumeric())
.collect()
}
@@ -0,0 +1,119 @@
//! Latency harness for the whole discography-load pipeline — the three
//! backend stages the artist page runs, in the order the frontend runs them:
//!
//! 1. `catalog_artist_overview` — the first-paint content stage
//! 2. `catalog_artist` — the exhaustive classification page
//! 3. `catalog_artist_identity` — the MusicBrainz supplement/type-hint pass
//!
//! Companion to `album_info_bench` (descriptions). Run with
//! `SOULSTREAM_STAGE_TRACE=1` to attribute time inside each stage.
//!
//! Usage:
//! cargo run -p metadata --example artist_page_bench -- "Artist" ["Artist" ...]
use metadata::MetadataClient;
use std::sync::Arc;
use std::time::Instant;
#[tokio::main]
async fn main() {
let artists: Vec<String> = std::env::args().skip(1).collect();
if artists.is_empty() {
eprintln!("usage: artist_page_bench -- \"Artist\" [\"Artist\" ...]");
return;
}
for artist in &artists {
let client = Arc::new(MetadataClient::new());
let search = client.catalog_search(artist).await;
let Some(id) = search
.artists
.iter()
.find(|a| normalize(&a.name) == normalize(artist))
.map(|a| a.id)
else {
println!("== {artist}: no exact-name artist found, skipping ==\n");
continue;
};
println!("== {artist} (deezer {id}) ==");
// The frontend overlaps overview + page, then runs the identity pass
// after the page lands. Reproduce exactly that shape.
let cold_started = Instant::now();
let (overview, page) = tokio::join!(
async {
let t = Instant::now();
let overview = client.catalog_artist_overview(id).await;
(t.elapsed().as_millis(), overview)
},
async {
let t = Instant::now();
let page = client.catalog_artist(id).await;
(t.elapsed().as_millis(), page)
},
);
let overlap_ms = cold_started.elapsed().as_millis();
println!(
" cold overview: {} ms ({} albums) cold page: {} ms ({} albums) overlapped: {overlap_ms} ms",
overview.0,
overview.1.as_ref().map(|o| o.albums.len()).unwrap_or(0),
page.0,
page.1.as_ref().map(|p| p.albums.len()).unwrap_or(0),
);
let t = Instant::now();
let identity = client.catalog_artist_identity(id, artist).await;
let identity_ms = t.elapsed().as_millis();
println!(
" cold identity pass: {identity_ms} ms (supplement {} albums, {} type hints)",
identity.albums.len(),
identity.type_hints.len(),
);
println!(
" COLD TOTAL to fully sorted (page ∥ overview, then identity): {} ms",
overlap_ms + identity_ms
);
let t = Instant::now();
let _ = client.catalog_artist(id).await;
println!(" warm page: {} ms", t.elapsed().as_millis());
let t = Instant::now();
let _ = client.catalog_artist_identity(id, artist).await;
println!(" warm identity: {} ms", t.elapsed().as_millis());
// The new frontend shape: the identity pass is kicked off BESIDE the
// page load instead of after it, so cold time-to-sorted is max(), not
// sum(). A fresh client so nothing above warms it.
let fresh = Arc::new(MetadataClient::new());
let t = Instant::now();
let (_page, _identity) = tokio::join!(
fresh.catalog_artist(id),
fresh.catalog_artist_identity(id, artist),
);
println!(
" cold page ∥ identity (new frontend overlap): {} ms",
t.elapsed().as_millis()
);
// Restart warmth: two separate clients sharing one disk cache dir —
// the second models an app relaunch.
let dir = std::env::temp_dir().join("soulstream-artist-page-bench-cache");
let _ = std::fs::remove_dir_all(&dir);
let writer = Arc::new(MetadataClient::new().with_disk_cache(dir.clone()));
let _ = writer.catalog_artist_identity(id, artist).await;
let restarted = Arc::new(MetadataClient::new().with_disk_cache(dir.clone()));
let t = Instant::now();
let _ = restarted.catalog_artist_identity(id, artist).await;
println!(
" identity after app restart (disk cache): {} ms\n",
t.elapsed().as_millis()
);
}
}
fn normalize(s: &str) -> String {
s.to_lowercase()
.chars()
.filter(|c| c.is_alphanumeric())
.collect()
}
+44
View File
@@ -0,0 +1,44 @@
//! Hardened cover-resolution report across a whole discography.
//! cargo run -p metadata --example cover_probe -- input.json output.json
//!
//! input.json: { "albums": [ { "artist": "...", "title": "...", "cover": "<url>" }, ... ] }
//! output.json: [ CoverReport, ... ] (reference + candidates + chosen, with dHash distances)
#[derive(serde::Deserialize)]
struct Album {
artist: String,
title: String,
cover: Option<String>,
}
#[derive(serde::Deserialize)]
struct Input {
albums: Vec<Album>,
}
#[tokio::main]
async fn main() {
let in_path = std::env::args().nth(1).expect("input.json");
let out_path = std::env::args().nth(2).expect("output.json");
let raw = std::fs::read_to_string(&in_path).expect("read input");
let input: Input =
serde_json::from_str(raw.trim_start_matches('\u{feff}')).expect("parse input");
let client = metadata::MetadataClient::new();
let mut reports = Vec::new();
let total = input.albums.len();
for (i, a) in input.albums.iter().enumerate() {
let reference = a.cover.clone().unwrap_or_default();
let report = client.cover_report(&a.artist, &a.title, &reference).await;
eprintln!(
"[{}/{}] {} :: chosen={:?} ({} candidates)",
i + 1,
total,
a.title,
report.chosen_source,
report.candidates.len()
);
reports.push(report);
}
std::fs::write(&out_path, serde_json::to_string(&reports).unwrap()).expect("write output");
eprintln!("wrote {out_path}");
}
+26
View File
@@ -0,0 +1,26 @@
//! Decode an image with the exact decoder the hero pipeline uses (the `image`
//! crate) and write lossless PNG. Lets the live-GPU hero prototype consume
//! bit-identical pixels to the baked path — browsers decode JPEG chroma
//! differently, which showed up as a subtle tint in A/B comparisons.
//!
//! Usage: decode_to_png <input.jpg> <output.png>
fn main() {
let mut args = std::env::args().skip(1);
let (Some(input), Some(output)) = (args.next(), args.next()) else {
eprintln!("usage: decode_to_png <input> <output.png>");
std::process::exit(2);
};
let bytes = std::fs::read(&input).expect("read input");
let decoded = image::load_from_memory(&bytes).expect("decode");
decoded
.save_with_format(&output, image::ImageFormat::Png)
.expect("write png");
println!(
"{} {}x{} -> {}",
input,
decoded.width(),
decoded.height(),
output
);
}
@@ -0,0 +1,124 @@
//! Broad discography audit over the PRODUCTION artist pipeline.
//!
//! Runs `catalog_artist` (Deezer + Apple supplement, dedup, edition folding)
//! plus the deferred `catalog_artist_identity` MusicBrainz pass for each
//! requested artist, and prints one JSON object per artist (JSONL) with the
//! final album rows and MB type hints — exactly the data the artist page
//! files into tabs. An external analyzer replicates the frontend's category
//! rules over this dump to flag duplicates and misfiled releases.
//!
//! Usage:
//! cargo run -p metadata --example discography_audit -- "Artist" ...
//! cargo run -p metadata --example discography_audit -- --broad
use metadata::MetadataClient;
use std::sync::Arc;
const BROAD_ARTISTS: [&str; 44] = [
"Weezer",
"Radiohead",
"Queen",
"The Beatles",
"Taylor Swift",
"Beyoncé",
"Coldplay",
"Nirvana",
"Fleetwood Mac",
"Elton John",
"David Bowie",
"Pink Floyd",
"Led Zeppelin",
"Bob Dylan",
"Miles Davis",
"Daft Punk",
"deadmau5",
"Fatboy Slim",
"Bonobo",
"Four Tet",
"Calvin Harris",
"Aphex Twin",
"The Chemical Brothers",
"Crazy P",
"Moby",
"Björk",
"Depeche Mode",
"Kanye West",
"Kendrick Lamar",
"Eminem",
"Drake",
"Travis Scott",
"Hans Zimmer",
"John Williams",
"Danny Elfman",
"Ludwig Göransson",
"Nine Inch Nails",
"MGMT",
"Panda Bear",
"Tame Impala",
"Mac DeMarco",
"Gorillaz",
"Metallica",
"ABBA",
];
#[tokio::main]
async fn main() {
let mut requested: Vec<String> = std::env::args().skip(1).collect();
if requested.iter().any(|a| a == "--broad") {
requested = BROAD_ARTISTS.iter().map(|s| s.to_string()).collect();
}
if requested.is_empty() {
eprintln!("pass artist names or --broad");
std::process::exit(2);
}
let client = Arc::new(MetadataClient::new());
for name in requested {
let search = client.catalog_search(&name).await;
let want = normalize(&name);
let artist = search
.artists
.iter()
.filter(|a| normalize(&a.name) == want)
.max_by_key(|a| a.fans)
.or_else(|| search.artists.first());
let Some(artist) = artist else {
println!(
"{}",
serde_json::json!({ "requested": name, "error": "not_found" })
);
continue;
};
let id = artist.id;
let display = artist.name.clone();
let fans = artist.fans;
let page = client.catalog_artist(id).await;
let Some(page) = page else {
println!(
"{}",
serde_json::json!({ "requested": name, "artist_id": id, "error": "page_failed" })
);
continue;
};
let identity = client.catalog_artist_identity(id, &display).await;
println!(
"{}",
serde_json::json!({
"requested": name,
"artist_id": id,
"artist": display,
"fans": fans,
"albums": page.albums,
"type_hints": identity.type_hints,
"identity_albums": identity.albums,
})
);
}
}
fn normalize(s: &str) -> String {
s.to_lowercase()
.chars()
.filter(|c| c.is_alphanumeric())
.collect()
}
@@ -0,0 +1,162 @@
//! Live diagnostic for the production artist-art resolver.
//!
//! Usage:
//! cargo run -p metadata --example hero_source_probe -- [output-dir] [artist ...]
//! cargo run -p metadata --example hero_source_probe -- [output-dir] --extended
//! cargo run -p metadata --example hero_source_probe -- [output-dir] --bing-only [artist ...]
//!
//! With no artists, the five core sourcing fixtures are used. `--extended`
//! adds ten lesser-known stress fixtures. Passing `-` as the output directory
//! prints URLs without saving the image bytes.
use metadata::{CatalogArtist, MetadataClient};
use std::path::PathBuf;
const DEFAULT_ARTISTS: [&str; 5] = [
"Camping in Alaska",
"Partiboi69",
"Hermeth",
"Billy Cobb",
"Fishmans",
];
const EXTENDED_ARTISTS: [&str; 10] = [
"Wednesday",
"Equipment",
"Hana Vu",
"Jane Remover",
"DJ Sabrina The Teenage DJ",
"Nikki Nair",
"MSPAINT",
"Horse Jumper of Love",
"They Are Gutting a Body of Water",
"Sword II",
];
#[tokio::main]
async fn main() {
let mut args = std::env::args().skip(1);
let output = args.next().filter(|value| value != "-").map(PathBuf::from);
let mut requested: Vec<String> = args.collect();
let bing_only = requested.iter().any(|value| value == "--bing-only");
requested.retain(|value| value != "--bing-only");
let artists: Vec<String> = if requested.is_empty() {
DEFAULT_ARTISTS
.iter()
.map(|name| name.to_string())
.collect()
} else if requested.len() == 1 && requested[0] == "--extended" {
DEFAULT_ARTISTS
.iter()
.chain(EXTENDED_ARTISTS.iter())
.map(|name| name.to_string())
.collect()
} else {
requested
};
if let Some(output) = output.as_ref() {
if let Err(error) = std::fs::create_dir_all(output) {
eprintln!("could not create {}: {error}", output.display());
std::process::exit(2);
}
}
let client = MetadataClient::new().with_hero_diagnostics();
for requested in artists {
// Bing-only diagnostics do not need a Deezer id. Keeping that path
// independent means catalog rate limits cannot turn a scraper probe
// into an immediate, misleading NOT_FOUND for every requested name.
let (artist_id, artist_name, fans) = if bing_only {
(None, requested, 0)
} else {
let search = client.catalog_search(&requested).await;
let Some(artist) = best_exact_artist(&search.artists, &requested) else {
println!("ARTIST\t{requested}\tNOT_FOUND");
continue;
};
(
Some(artist.id),
artist.name.clone(),
artist.fans.unwrap_or(0),
)
};
println!(
"ARTIST\t{}\t{}\t{}",
artist_id
.map(|id| id.to_string())
.unwrap_or_else(|| "BING_ONLY".to_string()),
artist_name,
fans,
);
let banners = if let Some(artist_id) = artist_id {
let Some(art) = client.catalog_artist_art(artist_id).await else {
println!("ERROR\t{}\tartist art unavailable", artist_name);
continue;
};
art.banners
} else {
client.debug_bing_hero_banners(&artist_name).await
};
if banners.is_empty() {
println!("BANNER\t{}\tNONE", artist_name);
continue;
}
for (index, url) in banners.iter().enumerate() {
println!("BANNER\t{}\t{}\t{}", artist_name, index + 1, url);
let Some(output) = output.as_ref() else {
continue;
};
let Some(bytes) = client.fetch_image_bytes(url).await else {
println!(
"SAVE_ERROR\t{}\t{}\tdownload failed",
artist_name,
index + 1
);
continue;
};
let extension = match image::guess_format(&bytes) {
Ok(image::ImageFormat::Png) => "png",
Ok(image::ImageFormat::WebP) => "webp",
_ => "jpg",
};
let path = output.join(format!(
"{}-{}.{}",
file_stem(&artist_name),
index + 1,
extension
));
match std::fs::write(&path, bytes) {
Ok(()) => println!("SAVED\t{}", path.display()),
Err(error) => println!("SAVE_ERROR\t{}\t{}", path.display(), error),
}
}
}
}
fn best_exact_artist<'a>(
artists: &'a [CatalogArtist],
requested: &str,
) -> Option<&'a CatalogArtist> {
artists
.iter()
.filter(|artist| artist.name.trim().eq_ignore_ascii_case(requested.trim()))
.max_by_key(|artist| artist.fans.unwrap_or(0))
}
fn file_stem(name: &str) -> String {
name.to_lowercase()
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() {
character
} else {
'-'
}
})
.collect::<String>()
.split('-')
.filter(|part| !part.is_empty())
.collect::<Vec<_>>()
.join("-")
}
+33
View File
@@ -0,0 +1,33 @@
//! Live check: MusicBrainz lookup + art fetch.
//! cargo run -p metadata --example lookup -- "Nirvana" "Nevermind"
#[tokio::main]
async fn main() {
let artist = std::env::args().nth(1).unwrap_or_else(|| "Nirvana".into());
let album = std::env::args()
.nth(2)
.unwrap_or_else(|| "Nevermind".into());
let client = metadata::MetadataClient::new();
match client.lookup_album(&artist, &album).await {
Some(meta) => {
println!(
"MB: {} — {} ({:?}) mbid={:?}",
meta.artist, meta.album, meta.year, meta.release_mbid
);
match client
.fetch_art(
meta.release_mbid.as_deref(),
meta.release_group_mbid.as_deref(),
&meta.artist,
&meta.album,
)
.await
{
Some(bytes) => println!("ART: {} bytes (starts {:02x?})", bytes.len(), &bytes[..4]),
None => println!("ART: none found"),
}
}
None => println!("MB: no match"),
}
}
@@ -0,0 +1,221 @@
//! 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()
}
File diff suppressed because it is too large. Load diff