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:
commit
a667b4b28e
161 files changed
+53170
No files matched your search
@@ -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()
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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("-")
|
||||
}
|
||||
@@ -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
Reference in new issue
Block a user