Files
meta-feeder-musicbrainz/feeder-plugin/musicbrainz-feeder/src/card.rs
T
Worph 50d58d41e1
Publish image / publish (push) Failing after 13s
rework domain field
2026-09-02 00:03:39 +02:00

1016 lines
43 KiB
Rust

//! The music card model and its projection to a `DiscoveryRecord`.
//!
//! A **card** identifies a *work* — an album, an artist — not a file. It
//! carries the display pair the quality gate needs (title + cover) plus an
//! **id bag** of every cross-source identifier the bridge resolved. Nothing
//! here is a byte locator; the card's own CID is a `0x1007` card-locator
//! derived from `(source, id)` and nothing is ever fetchable by it.
//!
//! The direct counterpart of `meta-feeder-card`'s `card.rs`, and deliberately
//! shaped the same so the two read side by side.
//!
//! ## Two kinds, one address space
//!
//! ⚠ **A music card locator takes no kind prefix**, unlike TMDB's
//! `tv:95479` / `movie:95479`. TMDB needs one because it reuses numeric ids
//! across media types; a MusicBrainz Identifier is a UUID and is unique across
//! every entity type, so `compute_card_cid("musicbrainz", "<mbid>")` is minted
//! from the bare id. This is the IMDb convention (`METADATA_KEYS.md` §4), and
//! it is why `record_id` is `"musicbrainz:<mbid>"` with nothing in between —
//! the record id *is* the CID preimage, so inserting a kind would silently
//! re-mint every card address.
use std::collections::BTreeMap;
use meta_feeder_sdk::hash::compute_card_cid;
use meta_feeder_sdk::types::DiscoveryRecord;
use crate::consts::{CAA_BASE, CAA_FRONT_SIZE, MAX_TRACK_COUNT};
use meta_feeder_sdk::hash::artwork_locator;
use crate::client::{normalise_date, Release};
/// The metadata source that published a card. The string form is the CID's
/// source namespace, so **changing it re-mints every card CID from that
/// source** — treat it as a wire constant.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CardSource {
MusicBrainz,
}
impl CardSource {
pub fn as_str(self) -> &'static str {
match self {
CardSource::MusicBrainz => "musicbrainz",
}
}
}
/// Which work a card describes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CardKind {
Album,
Artist,
}
impl CardKind {
/// The `contentKind` facet. Orthogonal to `fileType=card`.
pub fn content_kind(self) -> &'static str {
match self {
CardKind::Album => "album",
CardKind::Artist => "artist",
}
}
/// Which artwork field this kind writes. An album has a **sleeve**, an
/// artist has a **portrait**, and the registry keeps them apart rather than
/// calling both a `poster` the way the video tier does.
///
/// The value is a **`url` locator cid**, never a raw URL — see
/// [`artwork_locator`]. See METADATA_KEYS.md §6.
pub fn artwork_field(self) -> &'static str {
match self {
CardKind::Album => "cover",
CardKind::Artist => "photo",
}
}
}
/// Where a card's cover image comes from.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Cover {
/// A Cover Art Archive front image for a release-group. The endpoint
/// 307-redirects to the archive and **404s when nothing is illustrated**,
/// which is how a coverless work ends up gated out — see [`Card::to_record`].
ReleaseGroupFront { release_group_mbid: String },
/// A specific CAA image somebody already resolved for us (ListenBrainz
/// returns these). Strictly better than the front endpoint: it names an
/// image **known to exist**, so it cannot 404 into a hidden card.
Resolved { release_mbid: String, caa_id: i64 },
}
impl Cover {
pub fn url(&self) -> String {
match self {
Cover::ReleaseGroupFront { release_group_mbid } => {
format!("{CAA_BASE}/release-group/{release_group_mbid}/{CAA_FRONT_SIZE}")
}
Cover::Resolved {
release_mbid,
caa_id,
} => format!("{CAA_BASE}/release/{release_mbid}/{caa_id}-500.jpg"),
}
}
}
/// A resolved work.
///
/// Immutable by contract, exactly as a TMDB card is: no listen counts, no
/// popularity, no rating — nothing that drifts without the work changing.
/// `METADATA_KEYS.md` rule #4 forbids those on the shared hash anyway, and a
/// card is the one record type several peers derive independently at the same
/// CID, so a drifting value would make two peers disagree forever.
#[derive(Debug, Clone)]
pub struct Card {
pub source: CardSource,
pub kind: CardKind,
/// The bare MBID. Half of the card CID's preimage — see [`Card::record_id`].
pub mbid: String,
// -- display ------------------------------------------------------------
pub title: String,
/// Credited album artist (albums only). The *display* string, join phrases
/// included.
pub album_artist: Option<String>,
/// Every credited artist name, for the `artists/{name}` key-set.
pub artists: Vec<String>,
/// The primary artist's own MBID, when the credit resolved one.
pub artist_mbid: Option<String>,
pub cover: Option<Cover>,
/// MusicBrainz has no synopsis field. Its `disambiguation` ("live album",
/// "1997 remaster") is the nearest thing and is what fills
/// `description/eng` when present. The album gate deliberately does **not**
/// require it — see `meta-listen/ARCHITECTURE.md` §6.
pub disambiguation: Option<String>,
pub genres: Vec<String>,
/// Variable-precision MusicBrainz date, normalised on projection.
pub release_date: Option<String>,
// -- structure (albums, detail path only) --------------------------------
pub track_count: Option<u32>,
pub disc_count: Option<u32>,
pub label: Option<String>,
pub catalog_number: Option<String>,
/// `Album` / `Single` / `EP` / … Kept for the artist page's discography
/// grouping; not a `contentKind` (the work is an `album` either way).
pub primary_type: Option<String>,
pub secondary_types: Vec<String>,
/// The **canonical track list**, resolved on the detail path only.
///
/// This is what lets a consumer say "12 tracks, 9 playable" truthfully and
/// name the three it cannot get. Without it an album page can only render
/// the releases it happened to find, which silently redefines the album as
/// whatever is available — the failure mode the whole card tier exists to
/// avoid.
pub tracks: Vec<CanonicalTrack>,
/// Official places to *listen elsewhere* — MusicBrainz's `streaming` and
/// `free streaming` artist relations (Spotify, Tidal, Apple, Deezer, …),
/// measured present in the delegated-playback study §4.2.
///
/// Projected as `ext-play` (`0x1009`) locators in [`Card::to_record`], which
/// is where the reasoning for keeping them out of `cids/*` lives.
pub streaming_links: Vec<String>,
}
/// One entry of an album card's canonical track list.
///
/// Carries no CID and no availability: it says *what the album contains*, not
/// what anyone can fetch. Joining it to real releases is the consumer's job.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CanonicalTrack {
/// 1-based position within its disc.
pub position: u32,
/// 1-based disc index.
pub disc: u32,
pub title: String,
/// Milliseconds, when anyone has timed it.
pub length_ms: Option<u64>,
pub mb_recording_id: Option<String>,
pub isrc: Option<String>,
}
impl Card {
/// The feeder-scoped record id, `"musicbrainz:<mbid>"` — also the exact
/// preimage of the card CID, so `compute_outcomes` round-trips a record id
/// straight back to a CID with no lookup.
pub fn record_id(&self) -> String {
format!("{}:{}", self.source.as_str(), self.mbid)
}
/// This card's `0x1007` locator CID. Deterministic in `(source, mbid)`, so
/// any peer holding the MBID derives the same address offline.
pub fn cid(&self) -> Option<String> {
compute_card_cid(self.source.as_str(), &self.mbid)
}
/// The release type a discography groups by, folded from MusicBrainz's
/// primary + secondary types into one token.
///
/// **Secondary types win over the primary.** MusicBrainz says a live album
/// is `primary=Album, secondary=[Live]`, and grouping it under "Albums"
/// buries an artist's studio records under decades of concert recordings.
/// The same reasoning applies to compilations and soundtracks: what the
/// release *is* to a listener is the secondary type whenever there is one.
pub fn release_type(&self) -> Option<&'static str> {
for s in &self.secondary_types {
match s.trim().to_ascii_lowercase().as_str() {
"compilation" => return Some("Compilation"),
"live" => return Some("Live"),
"soundtrack" => return Some("Soundtrack"),
"remix" => return Some("Remix"),
"dj-mix" | "dj mix" => return Some("DJMix"),
_ => {}
}
}
match self.primary_type.as_deref()?.trim().to_ascii_lowercase().as_str() {
"album" => Some("Album"),
"single" => Some("Single"),
"ep" => Some("EP"),
"broadcast" => Some("Broadcast"),
"other" => Some("Other"),
_ => None,
}
}
/// Would this card actually render? Exactly [`Card::to_record`]'s emit
/// condition, hoisted so a caller can predict the drop *before* projecting.
///
/// ⚠ **This cannot predict the cover.** Unlike TMDB — whose response says
/// outright whether a cover exists — nothing in a MusicBrainz response
/// reveals whether the Cover Art Archive holds an image, and the fetch that
/// finds out happens in the **gateway core**, long after this feeder has
/// returned. So a card that passes here can still be hidden downstream. See
/// `consts::DISCOVERY_OVERFETCH` for the mitigation and why it is only a
/// mitigation.
pub fn is_displayable(&self) -> bool {
!self.title.trim().is_empty() && self.cover.is_some()
}
/// Project to the wire record.
///
/// Returns `None` when the card has no title or no cover source — it would
/// fail the consumer's quality gate and render as a hole in the grid, so
/// the feeder declines to emit it at all rather than shipping an
/// unrenderable card.
///
/// `query_filters` echoes back every structured filter the query carried,
/// which is **load-bearing**: both the gateway dispatcher and meta-search
/// re-apply `record_matches` to each record on the way out, and a *missing*
/// field fails the match — so an un-echoed `popular` means every card in
/// the row is dropped at one of the two tiers, and the row renders empty
/// with no error anywhere.
pub fn to_record(&self, query_filters: &BTreeMap<String, Vec<String>>) -> Option<DiscoveryRecord> {
let title = self.title.trim();
if title.is_empty() {
return None;
}
let cover = self.cover.as_ref()?;
let mut fields: BTreeMap<String, String> = BTreeMap::new();
// Type axes. `card` on the fileType axis and the work kind on the
// contentKind axis stay orthogonal, so `fileType:card contentKind:album`
// is a well-formed query and routing needs no special case.
fields.insert("fileType".to_string(), "card".to_string());
fields.insert("contentKind".to_string(), self.kind.content_kind().to_string());
// Third axis (METADATA_KEYS.md §1): MusicBrainz is the identity graph
// that *defines* the music domain, so both card kinds land there.
// Literal rather than via `meta_feeder_sdk::domain` — this feeder pins
// the SDK by git tag. An album and an artist are both closed works, not
// instalments of a run, so the fourth axis is `standalone`.
fields.insert("domain".to_string(), "music".to_string());
fields.insert("workForm".to_string(), "standalone".to_string());
fields.insert("title".to_string(), title.to_string());
// The id bag. Which field carries the card's own MBID depends on what
// the card *is* — an album is addressed by its release-group, an artist
// by its artist id.
match self.kind {
CardKind::Album => {
fields.insert("mbReleaseGroupId".to_string(), self.mbid.clone());
}
CardKind::Artist => {
fields.insert("mbArtistId".to_string(), self.mbid.clone());
}
}
if let Some(a) = self.artist_mbid.as_deref().filter(|s| !s.is_empty()) {
fields.insert("mbArtistId".to_string(), a.to_string());
}
if let Some(a) = self.album_artist.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
fields.insert("albumArtist".to_string(), a.to_string());
}
for a in &self.artists {
let a = a.trim();
if !a.is_empty() {
fields.insert(format!("artists/{a}"), "true".to_string());
}
}
if let Some(d) = self.release_date.as_deref().and_then(normalise_date) {
fields.insert("releasedate".to_string(), d);
}
if let Some(n) = self.track_count.filter(|n| *n > 0 && *n <= MAX_TRACK_COUNT) {
fields.insert("trackCount".to_string(), n.to_string());
}
if let Some(n) = self.disc_count.filter(|n| *n > 0) {
fields.insert("discCount".to_string(), n.to_string());
}
// The release *type*, which is what a discography groups by. Written on
// album cards only.
//
// ⚠ Distinct from `contentKind`, which stays `album` for every one of
// these. A single and a live album are both albums as far as the type
// axis is concerned — folding "is it a single?" into `contentKind`
// would make it a routing decision, and meta-search would then have to
// know the music vocabulary to fan a query out.
if self.kind == CardKind::Album {
if let Some(t) = self.release_type() {
fields.insert("releaseType".to_string(), t.to_string());
}
}
if let Some(l) = self.label.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
fields.insert("label".to_string(), l.to_string());
}
if let Some(c) = self
.catalog_number
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
fields.insert("catalogNumber".to_string(), c.to_string());
}
// MusicBrainz has no synopsis; `disambiguation` is the nearest thing.
// Written under the namespaced key meta-search indexes, not a flat
// `description` (METADATA_KEYS §14.6).
if let Some(d) = self
.disambiguation
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
fields.insert("description/eng".to_string(), d.to_string());
}
// Genres as a KEY-SET (`genres/<Name> = "true"`), never the legacy
// comma-joined value. METADATA_KEYS §14.12 forbids new csv-set writers,
// and the reason bites hardest here: a card is the one record type
// multiple peers derive independently at the same CID, so two peers
// resolving different genre sets must union by key-merge rather than
// diverge into two strings that need diffing to reconcile.
for g in &self.genres {
let g = g.trim();
if !g.is_empty() {
fields.insert(format!("genres/{g}"), "true".to_string());
}
}
// The canonical track list, as an INDEXED-LIST (`tracks/{n}/<field>` —
// `METADATA_KEYS.md` "Value formats"). The integer index carries
// position, which is what a set encoding could not express: a track
// list is ordered, and two tracks on the same album can share a title
// (a reprise, a hidden track), so key-set members would collide.
//
// Present only on a card resolved through the DETAIL path. A browse
// card carries none, because filling one would cost an extra
// MusicBrainz request per row entry against a 1 req/s budget — twenty
// seconds for a twenty-card row, past every consumer-side idle cutoff.
// Growth is append-only: the detail resolution adds fields to the same
// CID, it never edits one.
for (i, t) in self.tracks.iter().enumerate() {
fields.insert(format!("tracks/{i}/title"), t.title.clone());
fields.insert(format!("tracks/{i}/position"), t.position.to_string());
fields.insert(format!("tracks/{i}/disc"), t.disc.to_string());
if let Some(ms) = t.length_ms {
fields.insert(format!("tracks/{i}/length"), ms.to_string());
}
if let Some(id) = &t.mb_recording_id {
fields.insert(format!("tracks/{i}/mbRecordingId"), id.clone());
}
if let Some(isrc) = &t.isrc {
fields.insert(format!("tracks/{i}/isrc"), isrc.clone());
}
}
// The artwork, as a `url` LOCATOR cid — never a raw `*_url` field.
//
// METADATA_KEYS rule #5: *"it does not add a URL field — it emits a
// `url` locator CID"*. The feeder invariant against fetching bytes is
// no obstacle, because the locator is a pure function of the URL
// string: no request, no hash, no blockstore. The gateway core upgrades
// it to a real content cid when it seeds the bytes, and meta-share
// resolves it lazily if the gateway never got there.
//
// **Music vocabulary, not video's.** An album card writes `cover`, an
// artist card `photo`. The images come from the Cover Art Archive and a
// portrait is not a sleeve, so neither is a `poster`.
//
// ⚠ A 404 stays silent from here: the gateway deletes the field, and
// the consumer's gate then hides the card. That is the correct outcome
// (a coverless album card is a hole in a grid), but it means "row came
// back short" and "artwork missing" look identical downstream.
if let Some(locator) = artwork_locator(&cover.url()) {
fields.insert(self.kind.artwork_field().to_string(), locator);
}
// The bare-CID key-set member.
//
// **Load-bearing, and not merely cosmetic.** The gateway's search path
// persists a hit to meta-core only if it can find a CID on it — no key
// means the record is silently unpersisted. A card is the one record
// type whose CID is *derived* rather than discovered, and
// `compute_outcomes` (the other place it is derived) is not on the
// search path. Without this line a card is never persisted at all,
// which interacts badly with the gateway's search-coverage gate: it
// marks `(upstream, query)` covered whenever the feeder produced
// records and thereafter serves meta-core for an hour — coverage
// marked plus nothing persisted means **an identical search returns
// empty for the rest of the window**.
if let Some(cid) = self.cid() {
fields.insert(format!("cids/{cid}"), "true".to_string());
}
// Default provenance. The SDK copy this crate vendors (meta-gateway's)
// has no `stamp_default_source` helper — that lives only in the indexer
// feeder's copy — so it is stamped explicitly here rather than
// diverging the vendored SDK. See `cache.rs` for why the copy is kept
// byte-identical.
fields.insert(
format!("source/gateway:{}", self.source.as_str()),
"true".to_string(),
);
// ⚠ **`externalPlay/`, deliberately NOT `cids/`.**
//
// An `ext-play` locator ranks tier 5 — the same tier as the `card`
// locator that *is* this record's address. Putting one in the cid
// key-set would enter it into the canonical election as a peer of the
// card locator, and a tie there resolves on the lexicographically
// smaller cid: the work's own address could flip to a Spotify link,
// silently breaking every `work_key` route pointing at it.
//
// A separate key-set keeps them cid-shaped (rule #5 — never a raw
// `*_url`, which only *looks* transient) without letting them address
// the work. They are link affordances, never versions, and never
// queueable: an external page cannot report `ended`, so a queued one
// would stop playback silently.
for url in &self.streaming_links {
if let Some(cid) = meta_feeder_sdk::hash::compute_ext_play_cid(url) {
fields.insert(format!("externalPlay/{cid}"), "true".to_string());
}
}
// The filter echo (see the fn doc). `genres` and `languages` are
// excluded for the same reason the card feeder excludes them: both are
// key-sets, so the flat field this would write is not the shape a
// reader looks for, and the filter *value* is a slug
// (`hip-hop`) rather than a genre name — echoing it would persist a
// slug masquerading as a genre.
for (key, allowed) in query_filters {
if key == "languages" || key == "genres" || fields.contains_key(key) || allowed.is_empty()
{
continue;
}
fields.insert(key.clone(), allowed.join(","));
}
Some(DiscoveryRecord {
upstream_id: self.source.as_str().to_string(),
record_id: self.record_id(),
fields,
})
}
/// Fold a resolved canonical release into an album card: track/disc
/// counts, label and catalogue number. The detail-path enrichment a browse
/// row deliberately skips.
pub fn with_release_detail(mut self, release: &Release) -> Self {
let tracks: u32 = release
.media
.iter()
.map(|m| {
if m.track_count > 0 {
m.track_count
} else {
m.tracks.len() as u32
}
})
.sum();
if tracks > 0 {
self.track_count = Some(tracks.min(MAX_TRACK_COUNT));
}
if !release.media.is_empty() {
self.disc_count = Some(release.media.len() as u32);
}
if let Some(li) = release.label_info.first() {
self.label = li.label.as_ref().map(|l| l.name.clone()).filter(|s| !s.is_empty());
self.catalog_number = li.catalog_number.clone().filter(|s| !s.is_empty());
}
self.tracks = release
.media
.iter()
.flat_map(|m| {
// A medium's `position` is 1-based, but MusicBrainz omits it on
// single-disc releases — default to disc 1 rather than 0, or
// every track on a one-disc album reports disc 0 and a consumer
// sorting by (disc, track) puts them before disc 1.
let disc = if m.position == 0 { 1 } else { m.position };
m.tracks.iter().map(move |t| {
let rec = t.recording.as_ref();
CanonicalTrack {
// Same reasoning: `position` is the reliable ordinal
// (`number` is a printed string — vinyl uses `A1`).
position: t.position,
disc,
title: if t.title.trim().is_empty() {
rec.map(|r| r.title.clone()).unwrap_or_default()
} else {
t.title.clone()
},
length_ms: t.length.or_else(|| rec.and_then(|r| r.length)),
mb_recording_id: rec.map(|r| r.id.clone()).filter(|s| !s.is_empty()),
isrc: rec.and_then(|r| r.isrcs.first().cloned()),
}
})
})
.filter(|t| !t.title.trim().is_empty())
.collect();
self
}
}
/// Split a card `record_id` (`"musicbrainz:<mbid>"`) back into
/// `(source, mbid)` — the two halves of the CID preimage.
///
/// ⚠ `split_once`, not `rsplit_once`: an MBID contains hyphens but never a
/// colon, so the *first* colon is the source boundary.
pub fn split_record_id(record_id: &str) -> Option<(&str, &str)> {
record_id
.split_once(':')
.filter(|(s, id)| !s.is_empty() && !id.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
const RG: &str = "f5093c06-23e3-404f-aeaa-40f72885ee3a";
const AR: &str = "561d854a-6a28-4aa7-8c99-323e6ce46c2a";
fn album() -> Card {
Card {
source: CardSource::MusicBrainz,
kind: CardKind::Album,
mbid: RG.to_string(),
title: "Kind of Blue".to_string(),
album_artist: Some("Miles Davis".to_string()),
artists: vec!["Miles Davis".to_string()],
artist_mbid: Some(AR.to_string()),
cover: Some(Cover::ReleaseGroupFront {
release_group_mbid: RG.to_string(),
}),
disambiguation: None,
genres: vec!["Jazz".to_string(), "Modal Jazz".to_string()],
release_date: Some("1959-08-17".to_string()),
track_count: None,
disc_count: None,
label: None,
catalog_number: None,
primary_type: Some("Album".to_string()),
secondary_types: vec![],
tracks: Vec::new(),
streaming_links: Vec::new(),
}
}
#[test]
fn album_projects_the_expected_field_set() {
let r = album().to_record(&BTreeMap::new()).expect("record");
assert_eq!(r.upstream_id, "musicbrainz");
assert_eq!(r.record_id, format!("musicbrainz:{RG}"));
assert_eq!(r.fields.get("fileType").map(String::as_str), Some("card"));
assert_eq!(r.fields.get("contentKind").map(String::as_str), Some("album"));
assert_eq!(r.fields.get("title").map(String::as_str), Some("Kind of Blue"));
assert_eq!(r.fields.get("mbReleaseGroupId").map(String::as_str), Some(RG));
assert_eq!(r.fields.get("mbArtistId").map(String::as_str), Some(AR));
assert_eq!(
r.fields.get("albumArtist").map(String::as_str),
Some("Miles Davis")
);
assert_eq!(
r.fields.get("releasedate").map(String::as_str),
Some("1959-08-17")
);
// Genres are a key-set, never a comma-joined value.
assert_eq!(r.fields.get("genres/Jazz").map(String::as_str), Some("true"));
assert!(r.fields.get("genres").is_none());
// Provenance is always stamped.
assert_eq!(
r.fields.get("source/gateway:musicbrainz").map(String::as_str),
Some("true")
);
}
/// The `cids/<locator>` member is what makes the card persistable at all —
/// without it the gateway silently drops it and the search-coverage gate
/// then serves an empty result for an hour.
#[test]
fn album_stamps_its_own_locator_as_a_cid_member() {
let c = album();
let cid = c.cid().expect("locator");
let r = c.to_record(&BTreeMap::new()).expect("record");
assert_eq!(r.fields.get(&format!("cids/{cid}")).map(String::as_str), Some("true"));
}
/// ⚠ The no-kind-prefix rule. If this ever fails, every music card address
/// on the network has been re-minted and nothing resolves to what it used
/// to.
#[test]
fn the_locator_preimage_is_the_bare_mbid() {
let c = album();
assert_eq!(c.record_id(), format!("musicbrainz:{RG}"));
assert_eq!(
c.cid(),
compute_card_cid("musicbrainz", RG),
"the card CID must be minted from the BARE mbid — no album:/artist: prefix"
);
// And an artist card with the same id space does not collide, because
// MBIDs are globally unique across entity types.
assert_ne!(compute_card_cid("musicbrainz", RG), compute_card_cid("musicbrainz", AR));
}
/// The filter echo — an un-echoed filter means the record is dropped by
/// `record_matches` at the gateway or at meta-search, and the row renders
/// empty with no error.
#[test]
fn structured_filters_are_echoed_back_except_the_key_sets() {
let mut filters = BTreeMap::new();
filters.insert("popular".to_string(), vec!["true".to_string()]);
filters.insert("genres".to_string(), vec!["jazz".to_string()]);
filters.insert("languages".to_string(), vec!["eng".to_string()]);
let r = album().to_record(&filters).expect("record");
assert_eq!(r.fields.get("popular").map(String::as_str), Some("true"));
// The slug must NOT be persisted as if it were a genre name.
assert_eq!(r.fields.get("genres").map(String::as_str), None);
assert_eq!(r.fields.get("languages").map(String::as_str), None);
assert_eq!(r.fields.get("genres/Jazz").map(String::as_str), Some("true"));
}
/// The echo must never overwrite a field the card itself resolved.
#[test]
fn the_echo_does_not_clobber_a_resolved_field() {
let mut filters = BTreeMap::new();
filters.insert("contentKind".to_string(), vec!["album".to_string()]);
filters.insert("title".to_string(), vec!["something else".to_string()]);
let r = album().to_record(&filters).expect("record");
assert_eq!(r.fields.get("title").map(String::as_str), Some("Kind of Blue"));
assert_eq!(r.fields.get("contentKind").map(String::as_str), Some("album"));
}
#[test]
fn a_coverless_or_titleless_card_is_not_emitted() {
let mut c = album();
c.cover = None;
assert!(c.to_record(&BTreeMap::new()).is_none());
let mut c = album();
c.title = " ".to_string();
assert!(c.to_record(&BTreeMap::new()).is_none());
}
#[test]
fn artist_cards_are_addressed_by_their_artist_mbid() {
let c = Card {
kind: CardKind::Artist,
mbid: AR.to_string(),
title: "Miles Davis".to_string(),
album_artist: None,
artist_mbid: None,
..album()
};
let r = c.to_record(&BTreeMap::new()).expect("record");
assert_eq!(r.fields.get("contentKind").map(String::as_str), Some("artist"));
assert_eq!(r.fields.get("mbArtistId").map(String::as_str), Some(AR));
assert!(r.fields.get("mbReleaseGroupId").is_none());
assert!(r.fields.get("albumArtist").is_none());
}
#[test]
fn a_resolved_cover_beats_the_front_endpoint() {
let front = Cover::ReleaseGroupFront {
release_group_mbid: RG.to_string(),
};
assert!(front.url().ends_with(&format!("/release-group/{RG}/front-500")));
let resolved = Cover::Resolved {
release_mbid: "rel-1".to_string(),
caa_id: 42,
};
assert!(resolved.url().ends_with("/release/rel-1/42-500.jpg"));
}
#[test]
fn release_detail_folds_in_counts_label_and_catalogue() {
use crate::client::{Label, LabelInfo, Medium, Release, Track};
let release = Release {
id: "rel".into(),
title: "Kind of Blue".into(),
media: vec![
Medium {
position: 1,
track_count: 5,
tracks: vec![Track::default(); 5],
..Default::default()
},
Medium {
position: 2,
track_count: 3,
tracks: vec![Track::default(); 3],
..Default::default()
},
],
label_info: vec![LabelInfo {
catalog_number: Some("CL 1355".into()),
label: Some(Label {
name: "Columbia".into(),
}),
}],
..Default::default()
};
let c = album().with_release_detail(&release);
assert_eq!(c.track_count, Some(8));
assert_eq!(c.disc_count, Some(2));
assert_eq!(c.label.as_deref(), Some("Columbia"));
assert_eq!(c.catalog_number.as_deref(), Some("CL 1355"));
}
/// The medium's **declared** `track-count` wins over how many tracks were
/// inlined. The album genuinely has that many; a truncated response should
/// not shorten it. This is what lets a consumer say "12 tracks, 9 playable"
/// truthfully rather than quietly claiming the album is 9 tracks long.
#[test]
fn a_declared_track_count_beats_the_inlined_track_list() {
use crate::client::{Medium, Release, Track};
let release = Release {
media: vec![Medium {
position: 1,
track_count: 12,
tracks: vec![Track::default(); 2],
..Default::default()
}],
..Default::default()
};
assert_eq!(album().with_release_detail(&release).track_count, Some(12));
}
/// The canonical track list is what lets a consumer say "12 tracks, 9
/// playable" and *name* the three it cannot get. It is an indexed-list
/// because a track list is ordered and two tracks on one album can share a
/// title (a reprise, a hidden track) — key-set members would collide.
#[test]
fn the_canonical_track_list_rides_out_as_an_indexed_list() {
use crate::client::{Medium, Recording, Release, Track};
let release = Release {
media: vec![
Medium {
position: 1,
track_count: 2,
tracks: vec![
Track {
position: 1,
title: "So What".into(),
length: Some(545_000),
recording: Some(Recording {
id: "rec-1".into(),
isrcs: vec!["USSM15900001".into()],
..Default::default()
}),
..Default::default()
},
Track {
position: 2,
title: "Freddie Freeloader".into(),
..Default::default()
},
],
..Default::default()
},
Medium {
position: 2,
track_count: 1,
tracks: vec![Track {
position: 1,
title: "Blue in Green".into(),
..Default::default()
}],
..Default::default()
},
],
..Default::default()
};
let c = album().with_release_detail(&release);
assert_eq!(c.tracks.len(), 3);
let r = c.to_record(&BTreeMap::new()).expect("record");
assert_eq!(r.fields["tracks/0/title"], "So What");
assert_eq!(r.fields["tracks/0/position"], "1");
assert_eq!(r.fields["tracks/0/disc"], "1");
assert_eq!(r.fields["tracks/0/length"], "545000");
assert_eq!(r.fields["tracks/0/isrc"], "USSM15900001");
assert_eq!(r.fields["tracks/0/mbRecordingId"], "rec-1");
// A track with no timing simply omits the field rather than claiming 0.
assert!(!r.fields.contains_key("tracks/1/length"));
// Disc 2's track keeps its own disc index, and its position restarts —
// which is why (disc, position) is the sort key, not position alone.
assert_eq!(r.fields["tracks/2/disc"], "2");
assert_eq!(r.fields["tracks/2/position"], "1");
}
/// ⚠ A single-disc release omits the medium position, and defaulting it to
/// 0 puts every track *before* disc 1 in any (disc, position) sort.
#[test]
fn a_medium_with_no_position_is_disc_one() {
use crate::client::{Medium, Release, Track};
let release = Release {
media: vec![Medium {
position: 0,
track_count: 1,
tracks: vec![Track {
position: 1,
title: "Only Track".into(),
..Default::default()
}],
..Default::default()
}],
..Default::default()
};
let c = album().with_release_detail(&release);
assert_eq!(c.tracks[0].disc, 1);
}
/// ⚠ A live album is `primary=Album, secondary=[Live]`. Grouping it under
/// "Albums" buries an artist's studio records under decades of concert
/// recordings, so the secondary type wins.
#[test]
fn a_secondary_release_type_outranks_the_primary() {
let live = Card {
primary_type: Some("Album".into()),
secondary_types: vec!["Live".into()],
..album()
};
assert_eq!(live.release_type(), Some("Live"));
let comp = Card {
primary_type: Some("Album".into()),
secondary_types: vec!["Compilation".into()],
..album()
};
assert_eq!(comp.release_type(), Some("Compilation"));
// A plain studio album keeps its primary type.
assert_eq!(album().release_type(), Some("Album"));
let single = Card {
primary_type: Some("Single".into()),
secondary_types: vec![],
..album()
};
assert_eq!(single.release_type(), Some("Single"));
}
/// `releaseType` must not leak onto an artist card, and must never be
/// confused with `contentKind` — which stays `album` for every one of them.
#[test]
fn release_type_is_an_album_only_field_beside_content_kind() {
let r = Card {
primary_type: Some("Single".into()),
..album()
}
.to_record(&BTreeMap::new())
.expect("record");
assert_eq!(r.fields["releaseType"], "Single");
assert_eq!(r.fields["contentKind"], "album", "the routing axis is unchanged");
let artist = Card {
kind: CardKind::Artist,
mbid: AR.to_string(),
primary_type: Some("Group".into()),
..album()
}
.to_record(&BTreeMap::new())
.expect("record");
assert!(!artist.fields.contains_key("releaseType"));
}
/// A browse card carries no track list — filling one would cost an extra
/// MusicBrainz request per row entry against a 1 req/s budget.
#[test]
fn a_browse_card_emits_no_track_fields() {
let r = album().to_record(&BTreeMap::new()).expect("record");
assert!(
!r.fields.keys().any(|k| k.starts_with("tracks/")),
"a browse card must stay cheap"
);
}
/// …but a medium that declares nothing falls back to what it did inline,
/// rather than reporting zero tracks.
#[test]
fn an_undeclared_track_count_falls_back_to_the_inlined_list() {
use crate::client::{Medium, Release, Track};
let release = Release {
media: vec![Medium {
position: 1,
track_count: 0,
tracks: vec![Track::default(); 3],
..Default::default()
}],
..Default::default()
};
assert_eq!(album().with_release_detail(&release).track_count, Some(3));
}
/// An MBID is full of hyphens but never a colon — the source boundary is
/// the FIRST colon, and splitting from the right would shred the id.
#[test]
fn record_ids_split_on_the_first_colon() {
assert_eq!(
split_record_id(&format!("musicbrainz:{RG}")),
Some(("musicbrainz", RG))
);
assert!(split_record_id("nocolon").is_none());
assert!(split_record_id(":empty-source").is_none());
assert!(split_record_id("empty-id:").is_none());
}
}
#[cfg(test)]
mod ext_play_tests {
use super::*;
fn artist_card_with_links(links: &[&str]) -> Card {
Card {
source: CardSource::MusicBrainz,
kind: CardKind::Artist,
mbid: "056e4f3e-d505-4dad-8ec1-d04f521cbb56".into(),
title: "Daft Punk".into(),
album_artist: None,
artists: vec!["Daft Punk".into()],
artist_mbid: None,
cover: Some(Cover::ReleaseGroupFront { release_group_mbid: "rg".into() }),
disambiguation: None,
genres: vec![],
release_date: None,
track_count: None,
disc_count: None,
label: None,
catalog_number: None,
primary_type: None,
secondary_types: vec![],
tracks: Vec::new(),
streaming_links: links.iter().map(|s| s.to_string()).collect(),
}
}
/// **The hazard this key-set exists to avoid.**
///
/// An `ext-play` locator ranks tier 5, the same tier as the `card` locator
/// that addresses the work. In `cids/*` it would be a peer in the canonical
/// election, and a tie resolves on the lexicographically smaller cid — so
/// the artist's own address could flip to a Spotify link and break every
/// route pointing at it.
#[test]
fn streaming_links_never_enter_the_cid_keyset() {
let rec = artist_card_with_links(&["https://open.spotify.com/artist/4tZwfgrHOc3mvqYlEYSvVi"])
.to_record(&Default::default())
.expect("record");
let ext = meta_feeder_sdk::hash::compute_ext_play_cid(
"https://open.spotify.com/artist/4tZwfgrHOc3mvqYlEYSvVi",
)
.expect("mints");
assert!(
rec.fields.contains_key(&format!("externalPlay/{ext}")),
"the link is emitted"
);
// The record legitimately carries its OWN card locator under `cids/` —
// that is its address. What must never appear there is the ext-play
// locator, which would make a Spotify link a candidate for the
// canonical election alongside it.
assert!(
!rec.fields.contains_key(&format!("cids/{ext}")),
"an ext-play locator must never be a cid the work can be addressed by"
);
assert_eq!(
rec.fields.keys().filter(|k| k.starts_with("cids/")).count(),
1,
"exactly one cid: the card locator"
);
}
#[test]
fn a_card_with_no_links_emits_no_key_set() {
let rec = artist_card_with_links(&[]).to_record(&Default::default()).expect("record");
assert!(!rec.fields.keys().any(|k| k.starts_with("externalPlay/")));
}
/// Non-http payloads must never mint a locator: the client hands this
/// string to an anchor or a new tab.
#[test]
fn a_non_http_link_is_dropped() {
let rec = artist_card_with_links(&["javascript:alert(1)"])
.to_record(&Default::default())
.expect("record");
assert!(!rec.fields.keys().any(|k| k.starts_with("externalPlay/")));
}
}