Files
meta-feeder-youtube/feeder-plugin/youtube-feeder/src/youtube.rs
T
Worph dcdeefb95f
Publish image / publish (push) Failing after 13s
rework domain field
2026-09-02 00:01:54 +02:00

2040 lines
86 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The `youtube` upstream — the **delegated-playback** tier.
//!
//! Resolves a MusicBrainz release-group to per-track YouTube references so a
//! recording with no owned bytes anywhere is still *playable*.
//!
//! # Two tiers, and the line between them
//!
//! Everything in the "resolver" section below describes the **verified** paths
//! (`track`, `musicVideo`): id-anchored, channel- and duration-checked, and
//! built to refuse. A third path, [`YouTubePlugin::video_search`], answers a
//! plain `fileType:video` query with **unverified** free-text hits, because a
//! video client asking a video question deserves an answer even when nothing
//! can vouch for it.
//!
//! The two do not blur, and the reason is structural rather than editorial: raw
//! records never carry `anchored`, and a consumer's grid drops non-anchored
//! gateway hits unconditionally, so they can only ever land in a "raw sources"
//! section. Read the refusal doctrine below as scoped to the verified paths —
//! it is what keeps *those* trustworthy, and the raw tier is walled off from
//! them by the dispatch order in [`FeederPlugin::handle_query`], not by taste. It is the third
//! plugin in this binary, beside `musicbrainz` (identity) and
//! `internetarchive` (free bytes), and it lives here rather than in a repo of
//! its own because the MusicBrainz client, the rate budget, the cache and the
//! MBID→tracklist resolution it needs are all already written next door.
//!
//! # What makes this tier different from every other one
//!
//! Every other version in MetaMesh is *bytes at a cid meta-share can serve* —
//! owned already, or fetchable and then seeded. A YouTube reference is a third
//! thing: **playback we can only delegate**. Nothing is transported, seeded or
//! content-addressed, and the byte path REFUSES the cid (`400`) rather than
//! 404ing. See `docs/cid-formats.md` §7 and the delegated-playback study.
//!
//! Consequences that are architecture, not decoration:
//!
//! * The outcome is **metadata-only** — the `(None, Some)` branch, `bytes:
//! None`, no `handle_fetch`, no `get_blob`, no bytes ever.
//! * `HashKind::YtVideo` keeps it out of the blockstore seed, which is gated on
//! `Sha2_256` anyway — but the explicit kind is what lets the core, and any
//! future consumer, tell "no bytes *yet*" from "no bytes, ever".
//! * A reference is an **on-ramp, not a replacement**: because it hangs off the
//! same record as any future real cid, the version list upgrades in place and
//! the tier ordering demotes the reference automatically.
//!
//! # The resolver, and why it refuses more than it emits
//!
//! The gateway path here is well-trodden; the real risk is **match quality**,
//! and the asymmetry that makes it matter: a *missing* track shows up as an
//! honestly sparse album page, while a *wrong* one shows up as a working app
//! playing the wrong song. So this resolver is built to refuse.
//!
//! Measured over 83 tracks / 12 albums, deliberately unkind sample (study §7.1):
//! 72.3% EXACT (channel **and** duration agree), 15.7% TITLE, 3.6% genuine coin
//! flips. **Only EXACT is emitted.** That is the deliberate trade: 72.3%
//! coverage and an honestly sparser page, rather than 88.0% with a confidence
//! marker the UI would have to render.
//!
//! The two filters are both load-bearing and neither is sufficient alone:
//!
//! * **Channel id == the artist's YouTube-Music channel** answers "the right
//! artist, officially" as an exact id comparison rather than a bag of
//! heuristics (`- Topic` suffixes, verified badges, string similarity).
//! * **Duration within ±10 s of the MusicBrainz recording length** answers "the
//! right *recording*". MusicBrainz holds three "Get Lucky" recordings
//! (237/294/40 s) and Piped returned 370 s and 249 s; without duration you
//! cannot tell which one you queued.
use std::collections::{BTreeMap, HashMap};
use std::path::Path;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use meta_feeder_sdk::plugin::{ConfigError, FeederPlugin, GatewayQuery, HashKind, HashOutcome};
use meta_feeder_sdk::types::{ByteStream, DiscoveryRecord, GatewayError, Hash, PluginHealth};
use serde_json::Value;
use tracing::{debug, warn};
use meta_feeder_sdk::cache::MidhashCache;
use crate::consts::{
MAX_REFS_PER_RECORDING, MB_BURST, MB_RATE_PER_SEC, MB_TIMEOUT_SECS, PIPED_INSTANCES,
PIPED_TIMEOUT_SECS, USER_AGENT, VIDEO_RECORD_NS,
YT_CANDIDATES_PER_TRACK, YT_DURATION_TOLERANCE_SECS, YT_THUMB_BASE, YT_VIDEO_SEARCH_MAX,
};
use musicbrainz_feeder::client::{primary_artist, MusicBrainzClient};
use meta_feeder_sdk::budget::RateBudget;
/// Which key produced a channel match, in the priority order the resolver
/// tries them.
///
/// **Recorded on the record**, not just used and dropped. Even emitting EXACT
/// only, the key that produced a match is the difference between "MusicBrainz
/// says this is the artist's official music channel" and "a name match against
/// YouTube's own artist index agreed" — and retrofitting the distinction onto
/// records already replicated across the mesh is painful.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ChannelKey {
/// MusicBrainz `youtube music` relation — authoritative, sparse.
MbYoutubeMusic,
/// MusicBrainz `youtube` relation — the artist's *regular* channel.
///
/// Weaker, and the weakness is specific: for BTS this is not where YouTube
/// Music serves from, and matching on it scored **0/8** exact. It is not
/// useless either — Nujabes and Boards of Canada both matched 8/8 through
/// it, because artists with no separate YouTube Music artist channel get
/// their regular uploads surfaced by `music_songs`. Second, never first.
MbYoutube,
/// Piped's own `music_artists` index — name-matched, so the weakest key,
/// but it has **no coverage gap**, which is why it exists.
///
/// MusicBrainz channel coverage looked like a hard ceiling (77% exact where
/// a relation exists, **0%** where it does not — 0% *by construction*, since
/// with no channel id nothing can reach EXACT however obviously right it
/// is). Adding this third key lifted the sample from 53.0% to 72.3% EXACT
/// and cut the risky residue from 4.8% to 3.6%.
PipedMusicArtists,
}
impl ChannelKey {
/// The value written to the record's `ytMatchKey` field.
pub fn as_str(self) -> &'static str {
match self {
ChannelKey::MbYoutubeMusic => "mb:youtube-music",
ChannelKey::MbYoutube => "mb:youtube",
ChannelKey::PipedMusicArtists => "piped:music-artists",
}
}
}
/// An artist's candidate channels, best key first. Empty means "no key
/// available", which makes every track for that artist unresolvable — by
/// design, since EXACT requires a channel comparison.
#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize)]
pub struct ChannelKeys {
/// `(channel_id, key_label)` pairs in priority order.
pub ranked: Vec<(String, String)>,
}
/// `ytMatchKey` for the raw tier — the value that means "nothing verified this".
///
/// A *value*, not an absent field. Every record this feeder emits carries a
/// match key, so a reader never has to distinguish "unverified" from "written
/// by a build that predates match keys" — a distinction that is impossible to
/// recover once records are replicated across the mesh.
pub const RAW_MATCH_KEY: &str = "unverified:free-text";
/// One Piped search result, trimmed to what the filters read.
#[derive(Clone, Debug)]
struct Candidate {
video_id: String,
title: String,
uploader_channel: String,
duration_secs: i64,
/// The channel's *display name*. The music paths never read it — they
/// compare channel **ids**, which is the whole point of their filter — but
/// the raw tier has no id to compare against and puts the uploader in the
/// record's description instead, so a viewer can see who they are about to
/// watch. Empty when Piped omits it.
uploader: String,
}
/// The verdict for one (track, candidate) pair.
///
/// Only [`Verdict::Exact`] is ever emitted. The other variants exist so the
/// refusals are *named* — in particular [`Verdict::ChannelWrongCut`], which is
/// the single most dangerous shape in this tier and must never be widened into
/// a near-miss.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Verdict {
/// Channel **and** duration agree. Safe to emit.
Exact,
/// The channel agrees but the runtime does not — a **refusal**, never a
/// near-miss.
///
/// # The artist-credit trap
///
/// This is where classical breaks, and it breaks confidently. MusicBrainz
/// credits the **composer**, not the performer:
///
/// ```text
/// Glenn Gould / The Goldberg Variations -> MB artist-credit = "Johann Sebastian Bach"
/// ```
///
/// So the resolver looks up *Bach's* channel, finds *a* Goldberg
/// Variations on it, and the channel key — the strongest signal it has —
/// agrees. The probe scored those at **+32 s and +209 s**: right composer,
/// wrong performance. Widening the tolerance to "rescue" them would emit
/// someone else's recording under this track's name.
ChannelWrongCut,
/// No channel agreement, or nothing plausible at all.
NoMatch,
}
pub struct YouTubePlugin {
http: reqwest::Client,
cache: Option<MidhashCache>,
mb: Option<Arc<MusicBrainzClient>>,
/// Rotating cursor into [`PIPED_INSTANCES`] — see [`Self::piped_get`].
instance: AtomicUsize,
/// Test hook: pin Piped to a wiremock base instead of the pool.
piped_base: Option<String>,
/// Test hook: pin the MusicBrainz base.
mb_base: Option<String>,
}
impl Default for YouTubePlugin {
fn default() -> Self {
Self {
http: reqwest::Client::builder()
.user_agent(USER_AGENT)
.timeout(Duration::from_secs(PIPED_TIMEOUT_SECS))
.build()
.expect("build youtube http client"),
cache: None,
mb: None,
instance: AtomicUsize::new(0),
piped_base: None,
mb_base: None,
}
}
}
impl YouTubePlugin {
/// Test constructor: pin both upstreams at wiremock servers.
pub fn with_bases(piped_base: String, mb_base: String) -> Self {
Self {
piped_base: Some(piped_base),
mb_base: Some(mb_base),
..Default::default()
}
}
/// One GET against the Piped pool, rotating past a dead instance.
///
/// The rotation is the whole reliability story for this leg, and it is
/// affordable precisely because we take only the 11-character video id: a
/// dead instance costs a retry, not a broken stream.
async fn piped_get(&self, path_and_query: &str) -> Option<Value> {
if let Some(base) = &self.piped_base {
let url = format!("{}/{}", base.trim_end_matches('/'), path_and_query);
return self.http.get(&url).send().await.ok()?.json().await.ok();
}
let start = self.instance.load(Ordering::Relaxed);
for i in 0..PIPED_INSTANCES.len() {
let idx = (start + i) % PIPED_INSTANCES.len();
let url = format!("{}/{}", PIPED_INSTANCES[idx], path_and_query);
match self.http.get(&url).send().await {
Ok(r) if r.status().is_success() => match r.json::<Value>().await {
Ok(v) => {
// Stick to a working instance for subsequent calls.
self.instance.store(idx, Ordering::Relaxed);
return Some(v);
}
Err(e) => debug!(target: "meta-music", %url, error = %e, "piped: bad json"),
},
Ok(r) => {
debug!(target: "meta-music", %url, status = %r.status(), "piped: non-2xx, rotating")
}
Err(e) => debug!(target: "meta-music", %url, error = %e, "piped: transport, rotating"),
}
}
warn!(target: "meta-music", path = %path_and_query, "piped: every instance failed");
None
}
/// Resolve an artist's channel keys, best first.
///
/// Cached **positively only**: MusicBrainz answers `503` when crowded and
/// the client reports that the same way it reports an empty result, so
/// caching a negative would freeze a transport hiccup into "this artist has
/// no channel" forever. The probe fabricated exactly that verdict — it
/// reported "no YouTube channel in MusicBrainz" for artists that plainly
/// have one — until the failure path was separated from the empty one.
async fn channel_keys(&self, artist_mbid: &str, artist_name: &str) -> ChannelKeys {
let cache_key = artist_mbid;
if let Some(c) = self.cache.as_ref() {
if let Ok(Some(hit)) = c.get_yt_channel(cache_key) {
if let Ok(v) = serde_json::from_str::<ChannelKeys>(&hit) {
if !v.ranked.is_empty() {
return v;
}
}
}
}
let mut ranked: Vec<(String, String)> = Vec::new();
let deadline = Duration::from_secs(MB_TIMEOUT_SECS);
// Did the MusicBrainz leg actually *answer*? Not "did it find
// anything" — see the caching rule at the bottom of this function.
let mut mb_answered = artist_mbid.is_empty();
if let Some(mb) = self.mb.as_ref() {
if !artist_mbid.is_empty() {
if let Some(rels) = mb.artist_url_relations(artist_mbid, deadline).await {
mb_answered = true;
// `youtube music` first, `youtube` second — the order is a
// measurement, not a preference. See `ChannelKey`.
for (want, key) in [
("youtube music", ChannelKey::MbYoutubeMusic),
("youtube", ChannelKey::MbYoutube),
] {
for (ty, url) in &rels {
if ty.eq_ignore_ascii_case(want) {
if let Some(id) = channel_id_from_url(url) {
ranked.push((id, key.as_str().to_string()));
}
}
}
}
}
}
}
// The third key. Tried even when MusicBrainz answered, because it costs
// one search and covers the 4-of-12 albums MusicBrainz has no relation
// for at all. Ordered last, so MusicBrainz still wins where it has an
// answer — and where both answer they agree (Daft Punk resolves to
// `UCRr1xG_2WIDs18a6cIiCxeA` either way).
//
// Tried under every name MusicBrainz knows, not just the credited one:
// a non-Latin credit finds no exact-name channel even when the artist
// plainly has one (§7.2.1, `植松伸夫` vs `Nobuo Uematsu`).
let mut names = vec![artist_name.to_string()];
if let Some(mb) = self.mb.as_ref() {
if !artist_mbid.is_empty() {
names.extend(mb.artist_names(artist_mbid, deadline).await);
}
}
for name in names {
if let Some(id) = self.piped_artist_channel(&name).await {
if !ranked.iter().any(|(c, _)| *c == id) {
ranked.push((id, ChannelKey::PipedMusicArtists.as_str().to_string()));
}
break;
}
}
let keys = ChannelKeys { ranked };
// ⚠ **Cache only a COMPLETE answer** — and "complete" means the
// MusicBrainz leg answered, not that the list came back non-empty.
//
// This is the §7.5 trap in the form that actually bit, caught in a live
// smoke test rather than by reasoning. MusicBrainz answered `503`
// (`musicbrainz throttled; pausing budget`), `artist_url_relations`
// returned `None` like every other failure, the Piped key still
// succeeded — and a **non-empty but partial** list got written to a
// permanent cache. Daft Punk, which has both a `youtube` and a
// `youtube music` relation, was pinned to `piped:music-artists`
// forever: still resolving, still correct, but recorded under the
// weakest key and unable to ever improve.
//
// Guarding on `!ranked.is_empty()` alone does not catch this, because
// the degraded answer is not empty. The condition has to be about
// whether the *authoritative* source spoke.
if mb_answered && !keys.ranked.is_empty() {
if let (Some(c), Ok(json)) = (self.cache.as_ref(), serde_json::to_string(&keys)) {
let _ = c.put_yt_channel(cache_key, &json);
}
} else if !mb_answered {
debug!(
target: "meta-music",
artist = %artist_name,
"youtube: musicbrainz did not answer; using channel keys but NOT caching them"
);
}
keys
}
/// Piped's `music_artists` index — the coverage-gap filler.
///
/// Results are subscriber-ordered, so the first exact-name match is right
/// in practice even where two channels share a name (two "Burial" channels
/// exist: 37 k subscribers and 39).
async fn piped_artist_channel(&self, artist_name: &str) -> Option<String> {
let name = artist_name.trim();
if name.is_empty() {
return None;
}
let v = self
.piped_get(&format!(
"search?q={}&filter=music_artists",
urlencoding_encode(name)
))
.await?;
let items = v.get("items")?.as_array()?;
for it in items {
let uploader = it
.get("name")
.and_then(|n| n.as_str())
.unwrap_or_default();
if normalise_title(uploader) == normalise_title(name) {
if let Some(id) = it.get("url").and_then(|u| u.as_str()).and_then(channel_id_from_url) {
return Some(id);
}
}
}
None
}
/// The artist's **music videos** — the video tier of the same delegation.
///
/// # Why the correctness rule is different here, and why that is sound
///
/// A track is checked against a MusicBrainz *recording*: title, and a
/// `length` to separate the album cut from the radio edit. A music video
/// has no such anchor — MusicBrainz does not model one, so there is no
/// canonical list to be wrong against.
///
/// So the whole claim this makes is narrower, and the channel match carries
/// it completely: **"this video was uploaded by the artist's own official
/// channel."** That is verifiable exactly, it is the same channel key the
/// track path already resolved, and nothing weaker is asserted — no
/// "this is the video *of* that recording", which is the claim that would
/// need an anchor we do not have.
async fn music_videos(
&self,
query: &GatewayQuery,
max_results: usize,
) -> Result<Vec<DiscoveryRecord>, GatewayError> {
let Some(artist_mbid) = query
.filters
.get("mbArtistId")
.and_then(|v| v.first())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
else {
// Id-anchored, exactly as the track path is: there is no free-text
// browse to fall back to, and answering one would fill an artist's
// row with somebody else's videos.
return Ok(Vec::new());
};
let Some(mb) = self.mb.as_ref() else {
return Ok(Vec::new());
};
let deadline = Duration::from_secs(MB_TIMEOUT_SECS);
let Some(artist) = mb.artist(&artist_mbid, deadline).await else {
return Ok(Vec::new());
};
let channels = self.channel_keys(&artist_mbid, &artist.name).await;
if channels.ranked.is_empty() {
debug!(
target: "meta-music",
artist = %artist.name,
"youtube: no channel key; music videos would be unverifiable"
);
return Ok(Vec::new());
}
let Some(v) = self
.piped_get(&format!(
"search?q={}&filter=music_videos",
urlencoding_encode(&artist.name)
))
.await
else {
return Ok(Vec::new());
};
let mut out = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for c in parse_candidates(&v) {
if out.len() >= max_results {
break;
}
let Some((_, key)) = channels
.ranked
.iter()
.find(|(id, _)| *id == c.uploader_channel)
else {
continue; // not this artist's channel — the entire filter
};
if !seen.insert(c.video_id.clone()) {
continue;
}
if !self.is_embeddable(&c.video_id).await {
continue;
}
if let Some(rec) =
self.music_video_record(&artist_mbid, &artist.name, &c, key, &query.filters)
{
out.push(rec);
}
}
Ok(out)
}
/// Every EXACT candidate for one track, best first.
///
/// Rank is duration delta, then channel-key strength — a MusicBrainz
/// `youtube music` relation is a stronger claim than a name match against
/// Piped's own artist index, so it breaks ties.
async fn exact_candidates(
&self,
artist: &str,
track: &musicbrainz_feeder::client::Track,
want_secs: Option<i64>,
channels: &ChannelKeys,
) -> Vec<TrackRef> {
let mut cands = self.search_track(artist, &track.title).await;
// §7.5 — MusicBrainz's filed title is often not the title anyone says
// (`Lift Yr. Skinny Fists…`, `MAP OF THE SOUL : 7`), so a single
// exact-phrase query misses. Retry once on a normalised title before
// concluding the corpus has nothing.
if !cands.iter().any(|c| {
grade(c, &track.title, want_secs, &channels.ranked).0 == Verdict::Exact
}) {
let loose = normalise_title(&track.title);
if !loose.is_empty() && loose != track.title.to_lowercase() {
cands.extend(self.search_track(artist, &loose).await);
}
}
let mut out: Vec<TrackRef> = Vec::new();
for c in cands {
let (verdict, key) = grade(&c, &track.title, want_secs, &channels.ranked);
if verdict != Verdict::Exact {
continue;
}
let delta = want_secs.map(|w| (c.duration_secs - w).abs()).unwrap_or(i64::MAX);
if out.iter().any(|r| r.video_id == c.video_id) {
continue; // the two queries can return the same upload
}
out.push(TrackRef {
video_id: c.video_id,
title: c.title,
match_key: key.unwrap_or_default(),
delta,
});
}
out.sort_by_key(|r| (r.delta, key_priority(&r.match_key)));
out
}
/// Is this video embeddable **here**, right now?
///
/// YouTube's oEmbed endpoint needs no key, no quota and no account:
///
/// ```text
/// 200 -> embeddable 401 -> embedding denied or private
/// 404 -> gone
/// ```
///
/// This is what moves a dead reference from a play-time error 101/150 —
/// the worst place to discover it — to resolve time, where it simply is not
/// emitted. The Data API could report `regionRestriction` too, but it
/// cannot be bought at any price (10k units/day, `search.list` = 100 units),
/// so oEmbed carries the ubiquitous check and the Data API carries nothing.
///
/// **Positives are cached, negatives are not** — same rule as the channel
/// keys. A `401`/`404` is a real verdict, but a transport failure looks
/// identical from here, and freezing "this video is dead" into a permanent
/// cache on a network hiccup is the §7.5 trap wearing another hat.
async fn is_embeddable(&self, video_id: &str) -> bool {
let key = format!("oembed:{video_id}");
if let Some(c) = self.cache.as_ref() {
if matches!(c.get_yt_search(&key), Ok(Some(_))) {
return true;
}
}
let url = format!(
"https://www.youtube.com/oembed?url={}&format=json",
urlencoding_encode(&format!("https://www.youtube.com/watch?v={video_id}"))
);
let ok = match self.http.get(&url).send().await {
Ok(r) => r.status().is_success(),
Err(e) => {
debug!(target: "meta-music", %video_id, error = %e, "oembed probe failed; treating as embeddable");
// A failed probe is not a negative verdict. Emitting on doubt is
// the right side to err on: the client already degrades a dead
// reference gracefully (it notes and skips), whereas dropping a
// good one is silent.
return true;
}
};
if ok {
if let Some(c) = self.cache.as_ref() {
let _ = c.put_yt_search(&key, "1");
}
}
ok
}
/// Search `music_songs` for one track.
///
/// `filter=music_songs` matters: it queries the YouTube *Music* corpus, so
/// results are official audio rather than the video-first ranking of a
/// plain search.
async fn search_track(&self, artist: &str, title: &str) -> Vec<Candidate> {
let q = format!("{artist} {title}");
if let Some(c) = self.cache.as_ref() {
if let Ok(Some(hit)) = c.get_yt_search(&meta_feeder_sdk::cache::search_key("yt", &q)) {
if let Ok(v) = serde_json::from_str::<Value>(&hit) {
return parse_candidates(&v);
}
}
}
let Some(v) = self
.piped_get(&format!(
"search?q={}&filter=music_songs",
urlencoding_encode(&q)
))
.await
else {
// A failed search is NOT an empty one. Returning `[]` here would be
// correct-looking and wrong: it would let a Piped outage present as
// "this album has no YouTube versions". Nothing is cached, so the
// next attempt retries.
return Vec::new();
};
if let (Some(c), Ok(json)) = (self.cache.as_ref(), serde_json::to_string(&v)) {
let _ = c.put_yt_search(&meta_feeder_sdk::cache::search_key("yt", &q), &json);
}
parse_candidates(&v)
}
}
/// One recording's ranked, embeddable references, before global de-confliction.
struct TrackPick {
recording_mbid: String,
track: musicbrainz_feeder::client::Track,
/// Best first: smaller duration delta wins, then the stronger channel key.
refs: Vec<TrackRef>,
}
/// One EXACT reference, carrying what ranking and the record both need.
#[derive(Clone, Debug)]
struct TrackRef {
video_id: String,
title: String,
/// Which [`ChannelKey`] produced the match, as written to `ytMatchKey`.
match_key: String,
/// |candidate runtime − MusicBrainz length|, in seconds. The primary rank.
delta: i64,
}
/// **A video may belong to only one recording.**
///
/// In the study Burial's *Archangel* and *Near Dark* both resolved to
/// `VVa_aCSA9Jc` at EXACT — same channel, both durations inside tolerance.
/// They are different recordings, so at least one is wrong: a collision is
/// **proof of a bad match**, not a duplicate to tidy away. The tighter duration
/// delta keeps the claim and the weaker one drops the reference entirely rather
/// than emitting both.
///
/// A pure function over the gathered picks, deliberately: the previous form
/// mutated the output vector mid-loop and hand-patched stored indices, which is
/// exactly the shape that breaks silently once a track can contribute more than
/// one reference.
fn assign_references(picks: &mut [TrackPick]) {
// video_id -> (pick index, winning delta)
let mut claimed: HashMap<String, (usize, i64)> = HashMap::new();
for (i, pick) in picks.iter().enumerate() {
for r in &pick.refs {
match claimed.get(&r.video_id) {
Some(&(_, best)) if best <= r.delta => {}
_ => {
claimed.insert(r.video_id.clone(), (i, r.delta));
}
}
}
}
for (i, pick) in picks.iter_mut().enumerate() {
pick.refs
.retain(|r| claimed.get(&r.video_id).is_some_and(|&(owner, _)| owner == i));
}
}
/// Grade one candidate against a track. See [`Verdict`].
fn grade(
cand: &Candidate,
track_title: &str,
mb_length_secs: Option<i64>,
channels: &[(String, String)],
) -> (Verdict, Option<String>) {
let channel_hit = channels
.iter()
.find(|(id, _)| *id == cand.uploader_channel)
.map(|(_, key)| key.clone());
let Some(key) = channel_hit else {
return (Verdict::NoMatch, None);
};
// Duration is what separates the right recording from the right artist.
// A track MusicBrainz never timed cannot reach EXACT: with no length there
// is nothing to compare, and "the channel agreed" alone is precisely the
// classical trap.
let Some(want) = mb_length_secs else {
return (Verdict::ChannelWrongCut, Some(key));
};
if (cand.duration_secs - want).abs() > YT_DURATION_TOLERANCE_SECS {
return (Verdict::ChannelWrongCut, Some(key));
}
// Title agreement, on top of channel+duration. Duration alone is a coin
// flip wearing a plausible runtime — two real misses from the study, both
// inside one second: Burial "Archangel" -> "Near Dark" (+1 s), Adele
// "Don't You Remember" -> "Set Fire to the Rain" (+0 s).
if !titles_agree(&cand.title, track_title) {
return (Verdict::ChannelWrongCut, Some(key));
}
(Verdict::Exact, Some(key))
}
impl YouTubePlugin {
/// One `contentKind:musicVideo` record.
///
/// `fileType:video`, not `audio` — this is the video tier, and meta-listen's
/// artist row filters on exactly that pair.
fn music_video_record(
&self,
artist_mbid: &str,
artist: &str,
c: &Candidate,
match_key: &str,
query_filters: &BTreeMap<String, Vec<String>>,
) -> Option<DiscoveryRecord> {
let yt_cid = meta_feeder_sdk::hash::compute_yt_video_cid("video", &c.video_id)?;
let mut fields: BTreeMap<String, String> = BTreeMap::new();
fields.insert("fileType".to_string(), "video".to_string());
fields.insert("contentKind".to_string(), "musicVideo".to_string());
// Video bytes, music domain — the case the two axes exist for.
fields.insert("domain".to_string(), "music".to_string());
fields.insert("workForm".to_string(), "standalone".to_string());
fields.insert("title".to_string(), c.title.clone());
fields.insert("artist".to_string(), artist.to_string());
fields.insert(format!("artists/{}", artist.trim()), "true".to_string());
fields.insert("mbArtistId".to_string(), artist_mbid.to_string());
if c.duration_secs > 0 {
fields.insert("fileinfo/duration".to_string(), c.duration_secs.to_string());
}
fields.insert(format!("cids/{yt_cid}"), "true".to_string());
fields.insert("ytMatchKey".to_string(), match_key.to_string());
fields.insert("youtubeid".to_string(), c.video_id.clone());
fields.insert("source/gateway:youtube".to_string(), "true".to_string());
// `gate::keep` requires cover + artist for a musicVideo, and a missing
// cover drops the record silently — the same trap as the track path.
if let Some(locator) = meta_feeder_sdk::hash::artwork_locator(&format!(
"{YT_THUMB_BASE}/{}/hqdefault.jpg",
c.video_id
)) {
fields.insert("cover".to_string(), locator);
}
echo_filters(&mut fields, query_filters);
Some(DiscoveryRecord {
upstream_id: "youtube".to_string(),
record_id: track_record_id(artist_mbid, &c.video_id),
fields,
})
}
/// The raw tier's resolver: a free-text YouTube search, unverified.
///
/// Deliberately thin. There is no channel to compare against and no
/// duration to check, so there is nothing to *decide* — the ranking is
/// YouTube's own, the only judgement applied is a ceiling. Anything more
/// (a fuzzy title score, a "looks official" heuristic) would manufacture
/// confidence this tier has no basis for and cannot label.
async fn video_search(
&self,
query: &GatewayQuery,
max_results: usize,
) -> Result<Vec<DiscoveryRecord>, GatewayError> {
// No term, no answer. A `tmdbid` alone cannot drive this path: turning
// an id into a searchable title needs TMDB, which this feeder has no
// client for — and inventing a term from the id would search for the
// literal number. The anchored form is a different plugin's job (see
// the module TODO); this one refuses rather than guessing.
let term = query.free_text.trim().to_string();
if term.is_empty() {
debug!(
target: "meta-music",
"youtube: video query carries no free text; nothing to search on"
);
return Ok(Vec::new());
}
let cap = max_results.min(YT_VIDEO_SEARCH_MAX);
if cap == 0 {
return Ok(Vec::new());
}
let mut out = Vec::new();
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for c in self.search_videos(&term, cap).await {
if out.len() >= cap {
break;
}
// Piped can repeat a video across result shapes; a duplicate record
// id would collapse downstream anyway, but emitting it wastes a row
// of the ceiling.
if !seen.insert(c.video_id.clone()) {
continue;
}
if let Some(r) = self.video_record(&c, &query.filters) {
out.push(r);
}
}
debug!(
target: "meta-music",
term = %term,
hits = out.len(),
"youtube: raw video search"
);
Ok(out)
}
/// `filter=videos` — the general corpus, not the Music one.
///
/// The mirror of [`Self::search_track`]'s `filter=music_songs`: that filter
/// exists to bias towards official audio, which is the right corpus for a
/// recording and the wrong one for "a video of this thing".
///
/// Cached under its **own** namespace. Sharing `search_key("yt", …)` with
/// the track path would let a `music_songs` response answer a `videos`
/// query whenever the two terms happened to coincide — a silent corpus swap
/// that would look like a ranking quirk.
async fn search_videos(&self, term: &str, take: usize) -> Vec<Candidate> {
let key = meta_feeder_sdk::cache::search_key("yt-video", term);
if let Some(c) = self.cache.as_ref() {
if let Ok(Some(hit)) = c.get_yt_search(&key) {
if let Ok(v) = serde_json::from_str::<Value>(&hit) {
return parse_candidates_n(&v, take);
}
}
}
let Some(v) = self
.piped_get(&format!(
"search?q={}&filter=videos",
urlencoding_encode(term)
))
.await
else {
// Same rule as the track path: a failed search is NOT an empty one,
// and nothing is cached, so the next attempt retries. Caching a
// Piped outage as "no such video" is the partial-answer trap that
// pinned an artist to the wrong channel key for good.
return Vec::new();
};
if let (Some(c), Ok(json)) = (self.cache.as_ref(), serde_json::to_string(&v)) {
let _ = c.put_yt_search(&key, &json);
}
parse_candidates_n(&v, take)
}
/// One raw, unverified video record.
///
/// Returns `None` if the `yt-video` cid cannot be minted — a record with no
/// `cids/*` member is unplayable and would only add a dead row.
fn video_record(
&self,
c: &Candidate,
query_filters: &BTreeMap<String, Vec<String>>,
) -> Option<DiscoveryRecord> {
let yt_cid = meta_feeder_sdk::hash::compute_yt_video_cid("video", &c.video_id)?;
let mut fields: BTreeMap<String, String> = BTreeMap::new();
fields.insert("fileType".to_string(), "video".to_string());
// **No `contentKind`.** The music paths know what they resolved; this
// one genuinely does not, and a guess would be worse than a gap:
// `contentKind:pack` in particular is dropped outright by meta-watch's
// raw projection, and any wrong value narrows the record out of filters
// it should have matched. An absent axis is accepted everywhere.
fields.insert("title".to_string(), c.title.clone());
fields.insert(format!("cids/{yt_cid}"), "true".to_string());
fields.insert("youtubeid".to_string(), c.video_id.clone());
fields.insert("source/gateway:youtube".to_string(), "true".to_string());
if c.duration_secs > 0 {
fields.insert("fileinfo/duration".to_string(), c.duration_secs.to_string());
}
// The confidence signal, stated rather than implied. Every other record
// this feeder emits carries the channel key that verified it; this one
// carries the fact that nothing did. A consumer that later wants to
// rank or hide the raw tier has the field to do it with, and no reader
// has to infer "unverified" from the *absence* of a key.
fields.insert("ytMatchKey".to_string(), RAW_MATCH_KEY.to_string());
// Artwork + synopsis. Neither is needed for the raw bucket — meta-watch
// renders those rows without a poster — but a record outlives the query
// that produced it, and a consumer applying a poster+description gate
// (meta-listen's, or meta-watch's enriched grid should this hit ever be
// anchored by something else) would otherwise drop it silently. That
// silence is the expensive failure mode here, so pay two fields now.
//
// `poster_url`, not `cover`: `cover` is the music tier's sleeve
// vocabulary and this is a video record. The core fetches and seeds a
// `*_url` field (`remote_feeder::SEEDABLE_FIELDS`), which is why the
// per-query ceiling exists.
fields.insert(
"poster_url".to_string(),
format!("{YT_THUMB_BASE}/{}/hqdefault.jpg", c.video_id),
);
let uploader = c.uploader.trim();
fields.insert(
"description/eng".to_string(),
if uploader.is_empty() {
"Delegated playback — YouTube. Unverified search result.".to_string()
} else {
format!("Delegated playback — YouTube, {uploader}. Unverified search result.")
},
);
if !uploader.is_empty() {
fields.insert("uploader".to_string(), uploader.to_string());
}
// Echoes the query's own filters onto the record — which is how a
// `tmdbid:`-carrying query gets that id stamped on the hit, tying it to
// the work the consumer asked about. Note what this is NOT: echoing an
// id is not *verifying* one, and it deliberately does not set
// `anchored`. A record that claimed the anchor it merely inherited is
// the leak that once made a whole indexer's results look trusted.
echo_filters(&mut fields, query_filters);
Some(DiscoveryRecord {
upstream_id: "youtube".to_string(),
record_id: video_record_id(&c.video_id),
fields,
})
}
}
/// Rank order for [`ChannelKey`] as a tie-break: authoritative first.
fn key_priority(match_key: &str) -> u8 {
match match_key {
"mb:youtube-music" => 0,
"mb:youtube" => 1,
_ => 2,
}
}
/// Do two titles name the same recording?
///
/// Two rules, and the asymmetry between the costs is what shapes them: a false
/// *refusal* costs one row of coverage, a false *accept* puts the wrong song
/// under the right name. So this stays deliberately tight.
///
/// 1. **Containment, either direction** — because YouTube Music decorates a
/// title with the feature credits MusicBrainz keeps in the artist credit
/// ("Get Lucky" vs "Get Lucky (feat. Pharrell Williams and Nile Rodgers)").
/// 2. **High token overlap on longer titles** — because MusicBrainz's filed
/// title is often not the title anyone says, and the difference is a single
/// word: `Lift Yr. Skinny Fists…` against `Lift Your Skinny Fists…` shares
/// nine tokens of ten but fails containment outright.
///
/// Rule 2 is gated at [`TITLE_MIN_TOKENS`] tokens precisely so it cannot loosen
/// short titles, where a single shared word is most of the title and overlap
/// stops discriminating.
fn titles_agree(a: &str, b: &str) -> bool {
let (na, nb) = (normalise_title(a), normalise_title(b));
if na.is_empty() || nb.is_empty() {
return false;
}
if na.contains(&nb) || nb.contains(&na) {
return true;
}
let ta: std::collections::BTreeSet<&str> = na.split(' ').collect();
let tb: std::collections::BTreeSet<&str> = nb.split(' ').collect();
let smaller = ta.len().min(tb.len());
if smaller < TITLE_MIN_TOKENS {
return false;
}
let shared = ta.intersection(&tb).count();
shared as f32 / smaller as f32 >= TITLE_MIN_OVERLAP
}
/// Token count below which [`titles_agree`] rule 2 does not apply.
const TITLE_MIN_TOKENS: usize = 4;
/// Fraction of the shorter title's tokens that must be shared under rule 2.
const TITLE_MIN_OVERLAP: f32 = 0.8;
/// Lowercase, drop parentheticals, strip non-alphanumerics, collapse spaces.
///
/// MusicBrainz's title is often not the title anyone says — `Lift Yr. Skinny
/// Fists Like Antennas to Heaven!` and `MAP OF THE SOUL : 7` are the filed
/// forms — so an exact-phrase comparison misses both.
fn normalise_title(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut depth = 0usize;
for ch in s.chars() {
match ch {
'(' | '[' => depth += 1,
')' | ']' => depth = depth.saturating_sub(1),
_ if depth > 0 => {}
c if c.is_alphanumeric() => out.extend(c.to_lowercase()),
_ => out.push(' '),
}
}
out.split_whitespace().collect::<Vec<_>>().join(" ")
}
/// Extract a bare channel id from any of the URL shapes MusicBrainz and Piped
/// use (`https://www.youtube.com/channel/UC…`, `/channel/UC…`).
fn channel_id_from_url(url: &str) -> Option<String> {
let (_, tail) = url.split_once("/channel/")?;
let id = tail.split(['/', '?', '#']).next()?.trim();
(!id.is_empty()).then(|| id.to_string())
}
/// Extract a bare video id from Piped's `/watch?v=<id>` url field.
fn video_id_from_url(url: &str) -> Option<String> {
let (_, tail) = url.split_once("v=")?;
let id = tail.split(['&', '#']).next()?.trim();
(!id.is_empty()).then(|| id.to_string())
}
fn parse_candidates(v: &Value) -> Vec<Candidate> {
parse_candidates_n(v, YT_CANDIDATES_PER_TRACK)
}
/// [`parse_candidates`] with an explicit ceiling.
///
/// The music paths want [`YT_CANDIDATES_PER_TRACK`] — a *verification* budget,
/// sized by how deep into the ranking a correct match plausibly sits. The raw
/// tier's ceiling is a different quantity entirely (how many rows to show), so
/// it passes its own rather than inheriting a constant that means something
/// else. Same parse either way: `filter=videos` and `filter=music_*` return the
/// same item shape.
fn parse_candidates_n(v: &Value, take: usize) -> Vec<Candidate> {
let Some(items) = v.get("items").and_then(|i| i.as_array()) else {
return Vec::new();
};
items
.iter()
.take(take)
.filter_map(|it| {
Some(Candidate {
video_id: video_id_from_url(it.get("url")?.as_str()?)?,
title: it.get("title")?.as_str()?.to_string(),
uploader_channel: it
.get("uploaderUrl")
.and_then(|u| u.as_str())
.and_then(channel_id_from_url)
.unwrap_or_default(),
duration_secs: it.get("duration").and_then(|d| d.as_i64()).unwrap_or(-1),
uploader: it
.get("uploaderName")
.and_then(|u| u.as_str())
.unwrap_or_default()
.to_string(),
})
})
.collect()
}
/// Minimal percent-encoder for a query-string value.
fn urlencoding_encode(s: &str) -> String {
let mut out = String::with_capacity(s.len() * 3);
for b in s.as_bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(*b as char)
}
b' ' => out.push('+'),
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
/// `<mbRecordingId>~<videoId>` — the record id this plugin mints.
///
/// # Why the video id is *in* the id
///
/// It makes [`YouTubePlugin::compute_outcomes`] a **pure function with zero
/// I/O**, exactly as `compute_card_cid` is for the card feeder. The alternative
/// — keying only on the recording and re-resolving at outcome time — would
/// re-run a MusicBrainz tracklist, a channel lookup and a Piped search, and
/// could legitimately pick a *different* video than the one the consumer is
/// looking at. Minting a reference other than the one on screen is worse than
/// slow.
///
/// It also gives the multi-reference design its natural shape: several ranked
/// references for one recording are several records, each stably addressed,
/// each converging across peers via the `cids/` reverse index — rather than one
/// record whose meaning silently changes when the resolver's pick moves.
pub fn track_record_id(recording_mbid: &str, video_id: &str) -> String {
format!("{recording_mbid}~{video_id}")
}
/// Inverse of [`track_record_id`] — `(recording_mbid, video_id)`.
pub fn split_track_record_id(record_id: &str) -> Option<(&str, &str)> {
let (rec, vid) = record_id.split_once('~')?;
(!rec.is_empty() && !vid.is_empty()).then_some((rec, vid))
}
/// `yt~<videoId>` — the record id the **raw** tier mints.
///
/// Shares [`split_track_record_id`]'s shape on purpose, so `compute_outcomes`
/// stays one zero-I/O function serving both tiers: it only ever needs the right
/// half. The left half is [`VIDEO_RECORD_NS`] rather than a second MBID because
/// a raw hit has no recording behind it — and a constant makes the id a pure
/// function of the video, so the same video found via two different search
/// terms dedupes to one record instead of two.
pub fn video_record_id(video_id: &str) -> String {
format!("{VIDEO_RECORD_NS}~{video_id}")
}
/// Did the query **explicitly** ask for this file type?
///
/// Explicit, unlike [`wants_content_kind`]: an absent axis means "no
/// preference", and treating that as a video request would let the raw tier
/// answer questions nobody pointed at it. The music paths can afford the
/// permissive reading because an id anchor follows; this one cannot.
fn requests_file_type(query: &GatewayQuery, file_type: &str) -> bool {
query
.filters
.get("fileType")
.is_some_and(|v| v.iter().any(|x| x.trim().eq_ignore_ascii_case(file_type)))
}
/// Does the query carry a MusicBrainz anchor of any kind?
///
/// The guard that keeps the raw tier out of the verified tiers' way. If a
/// caller handed us an id, there is a path that can *verify* an answer, and
/// serving a free-text guess instead would be a silent downgrade — the kind
/// that shows up as "it used to be right".
fn has_musicbrainz_anchor(query: &GatewayQuery) -> bool {
["mbReleaseGroupId", "mbArtistId", "mbRecordingId"]
.iter()
.any(|k| {
query
.filters
.get(*k)
.is_some_and(|v| v.iter().any(|s| !s.trim().is_empty()))
})
}
#[async_trait]
impl FeederPlugin for YouTubePlugin {
fn upstream_id(&self) -> &'static str {
"youtube"
}
fn served_file_types(&self) -> &'static [&'static str] {
&["audio", "video"]
}
/// `track` and `musicVideo` — deliberately **not** `pack`.
///
/// A pack is a container whose members are separately addressable, and
/// there is no such thing as a delegated *album*: each reference is one
/// video. Claiming `pack` would advertise a capability that can only
/// answer with a synthetic container holding nothing fetchable.
///
/// `musicVideo` is the *video* tier of the same delegation, and it is what
/// finally fills meta-listen's artist-page "Music videos" row — empty until
/// now because nothing in the platform produced the content kind. YouTube
/// hosts the Art Track and the music video as **separate video ids**, which
/// is exactly what makes the YouTube-Music-style song/video toggle
/// implementable: swap the reference, keep the position.
fn served_content_kinds(&self) -> &'static [&'static str] {
&["track", "musicVideo"]
}
fn configure(&mut self, cache_dir: &Path) -> Result<(), ConfigError> {
let cache = MidhashCache::open(cache_dir).map_err(|e| ConfigError::Other {
plugin: "youtube",
source: anyhow::anyhow!("open music cache: {e}"),
})?;
// Its own MusicBrainz budget rather than a shared one: MusicBrainz's
// 1 req/s ceiling is enforced per source IP, and a budget shared with
// the card plugin would let a board refresh and an album open burst
// past it together.
let budget = RateBudget::new(MB_RATE_PER_SEC, MB_BURST);
let mb = match &self.mb_base {
Some(base) => MusicBrainzClient::with_base(base.clone(), budget, cache.clone()),
None => MusicBrainzClient::new(budget, cache.clone()),
};
self.mb = Some(Arc::new(mb));
self.cache = Some(cache);
Ok(())
}
fn health(&self) -> PluginHealth {
match self.mb {
Some(_) => PluginHealth::Ok,
None => PluginHealth::Degraded {
reason: "configure() not yet called".to_string(),
},
}
}
/// Resolve one release-group's tracks to YouTube references.
///
/// # This plugin is the mirror image of `internetarchive`
///
/// The archive holds no MusicBrainz ids, so it must return **nothing** for
/// an id-anchored query. This plugin can *only* work from one: you cannot
/// search YouTube for a UUID, so the chain is necessarily
/// `mbReleaseGroupId` → tracklist (titles + `length` + `mbRecordingId`) →
/// artist channel → per-track search → filter. A free-text query therefore
/// returns nothing here — not because it would be expensive, but because
/// the resolver has no anchor to verify a match against, and an unverified
/// match is the one output this tier must never produce.
async fn handle_query(
&self,
query: &GatewayQuery,
max_results: usize,
) -> Result<Vec<DiscoveryRecord>, GatewayError> {
if !meta_feeder_sdk::query_eval::query_accepts_plugin(
query,
self.served_file_types(),
self.served_content_kinds(),
) {
return Ok(Vec::new());
}
// Two anchors, two answers. The music-video row asks
// `mbArtistId:<uuid> fileType:video contentKind:musicVideo`, which is a
// different question from an album page's
// `mbReleaseGroupId:<uuid> fileType:audio` — different entity, different
// corpus, different correctness criterion. Dispatch on which anchor is
// present rather than trying to serve both from one path.
if wants_content_kind(query, "musicVideo") && !wants_content_kind(query, "track") {
return self.music_videos(query, max_results).await;
}
// **The raw video tier.** Everything above this line is id-anchored and
// refuses far more than it emits; this branch is the deliberate
// exception, and the reason it is safe is *structural*, not a matter of
// resolver discipline.
//
// A consumer's untyped free-text search never reaches a gateway at all
// (meta-search's load gate), so the query that lands here has already
// said `fileType:video` — a video client asking a video question. What
// it cannot say is *which* work it means, and there is no anchor to
// verify a match against. So this path emits **unverified** hits, which
// every other path in this module exists to prevent.
//
// What makes that honest rather than a regression: these records carry
// no `anchored` flag, and meta-watch's `release_from_record` drops every
// non-anchored gateway hit from the enriched grid *unconditionally* —
// poster and synopsis do not buy a way in. They can therefore only
// surface in the "raw sources" section, which is precisely the UI
// affordance for "found it, can't vouch for it". The tier is
// second-class by construction, not by convention.
//
// Ordering matters here and is load-bearing:
//
// * `musicVideo` is matched **above**, so meta-listen's artist row
// (`mbArtistId:… fileType:video contentKind:musicVideo`) still takes
// the verified path and never falls through to this one.
// * The `mbReleaseGroupId` track path below is `fileType:audio`, so it
// cannot reach this branch either.
// * `has_musicbrainz_anchor` is belt-and-braces for both: an id-anchored
// query has something better than a free-text guess available, and
// answering it from here would quietly downgrade a verified tier.
if requests_file_type(query, "video") && !has_musicbrainz_anchor(query) {
return self.video_search(query, max_results).await;
}
if !wants_content_kind(query, "track") {
return Ok(Vec::new());
}
let Some(rg_mbid) = query
.filters
.get("mbReleaseGroupId")
.and_then(|v| v.first())
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
else {
debug!(
target: "meta-music",
"youtube: query carries no mbReleaseGroupId; nothing to anchor on"
);
return Ok(Vec::new());
};
let Some(mb) = self.mb.as_ref() else {
return Ok(Vec::new());
};
let deadline = Duration::from_secs(MB_TIMEOUT_SECS);
let Some(release) = mb.tracklist(&rg_mbid, deadline).await else {
// Not "this album has no tracks" — the lookup did not answer. Never
// conflate the two (study §7.5); emit nothing and let the next
// attempt retry.
debug!(target: "meta-music", rg = %rg_mbid, "youtube: no tracklist");
return Ok(Vec::new());
};
// Whose channel to look for. The release-group's credited artist is the
// right *starting* point, but see `Verdict::ChannelWrongCut`: for
// classical this is the composer, and the duration filter is what stops
// that from resolving to someone else's performance.
let rg = mb.release_group(&rg_mbid, deadline).await;
let (artist_mbid, artist_name) = rg
.as_ref()
.and_then(|g| primary_artist(&g.artist_credit))
.map(|a| (a.id.clone(), a.name.clone()))
.unwrap_or_default();
let channels = self.channel_keys(&artist_mbid, &artist_name).await;
if channels.ranked.is_empty() {
debug!(
target: "meta-music",
artist = %artist_name,
"youtube: no channel key for this artist; every track would be unverifiable"
);
return Ok(Vec::new());
}
let album_title = release.title.clone();
// ── 1. Gather ranked EXACT candidates per track ──────────────────────
//
// Ranked, not reduced to a single best: §3's rot mitigation is to carry
// *several* references per recording so a client can fall through when
// one is taken down, geo-blocked or has embedding disabled. A single
// reference means a takedown kills the track outright.
let mut picks: Vec<TrackPick> = Vec::new();
for medium in &release.media {
for track in &medium.tracks {
let recording_mbid = track
.recording
.as_ref()
.map(|r| r.id.clone())
.unwrap_or_default();
if recording_mbid.is_empty() {
// Without it the consumer matches versions to track rows by
// fuzzy title and lands on the wrong row for anything with a
// reprise, a remix or a live variant.
continue;
}
let want_secs = track
.length
.or_else(|| track.recording.as_ref().and_then(|r| r.length))
.map(|ms| (ms / 1000) as i64);
let mut refs = self.exact_candidates(&artist_name, track, want_secs, &channels).await;
// Probe only what could plausibly be emitted — one spare, so a
// single dead reference does not cost the track a slot.
refs.truncate(MAX_REFS_PER_RECORDING + 1);
let mut embeddable = Vec::new();
for r in refs {
if self.is_embeddable(&r.video_id).await {
embeddable.push(r);
}
}
if embeddable.is_empty() {
continue;
}
picks.push(TrackPick {
recording_mbid,
track: track.clone(),
refs: embeddable,
});
}
}
// ── 2. One video may belong to only one recording ────────────────────
assign_references(&mut picks);
// ── 3. Emit ─────────────────────────────────────────────────────────
let mut out: Vec<DiscoveryRecord> = Vec::new();
for pick in &picks {
for (rank, r) in pick.refs.iter().take(MAX_REFS_PER_RECORDING).enumerate() {
if out.len() >= max_results {
break;
}
if let Some(rec) = self.track_record(
&rg_mbid,
&pick.recording_mbid,
&album_title,
&artist_name,
&pick.track,
r,
rank as u32,
&query.filters,
) {
out.push(rec);
}
}
}
out.truncate(max_results);
Ok(out)
}
/// Metadata-only: a reference, never bytes.
///
/// The `(None, Some)` branch — `bytes: None`, so the core auto-stores the
/// record and stops. There is no blockstore seed (gated on `Sha2_256`), no
/// WebDAV blob, and no `file_extension` (which MUST be `None` when `bytes`
/// is).
/// Serves **both** tiers: the left half of the id is whatever minted it (a
/// MusicBrainz recording, or [`VIDEO_RECORD_NS`] for a raw hit) and is
/// deliberately unread — the cid is a pure function of the video id alone,
/// so there is nothing here to branch on.
async fn compute_outcomes(&self, record_id: &str) -> Result<Vec<HashOutcome>, GatewayError> {
let (_minted_by, video_id) = split_track_record_id(record_id).ok_or_else(|| {
GatewayError::Permanent(format!("malformed youtube record_id '{record_id}'"))
})?;
let hash = meta_feeder_sdk::hash::compute_yt_video_cid("video", video_id).ok_or_else(
|| GatewayError::Permanent(format!("video id too long to encode: '{video_id}'")),
)?;
// `record: None` — unlike the card feeder there is nothing to
// re-resolve. `handle_query` already wrote the full record, and the
// only thing this cid could be re-derived from is the video id already
// in the record_id.
Ok(vec![HashOutcome {
hash: Hash(hash),
hash_kind: HashKind::YtVideo,
bytes: None,
record: None,
file_extension: None,
}])
}
/// Never. This tier has no byte path by construction.
async fn handle_fetch(&self, _record_id: &str) -> Result<Option<ByteStream>, GatewayError> {
Ok(None)
}
}
impl YouTubePlugin {
/// Build one delegated-playback track record.
///
/// Returns `None` if the `yt-video` cid cannot be minted — a record with no
/// `cids/*` member is unplayable and would only add a dead row.
#[allow(clippy::too_many_arguments)]
fn track_record(
&self,
rg_mbid: &str,
recording_mbid: &str,
album: &str,
artist: &str,
track: &musicbrainz_feeder::client::Track,
r: &TrackRef,
rank: u32,
query_filters: &BTreeMap<String, Vec<String>>,
) -> Option<DiscoveryRecord> {
let yt_cid = meta_feeder_sdk::hash::compute_yt_video_cid("video", &r.video_id)?;
let mut fields: BTreeMap<String, String> = BTreeMap::new();
fields.insert("fileType".to_string(), "audio".to_string());
fields.insert("contentKind".to_string(), "track".to_string());
fields.insert("domain".to_string(), "music".to_string());
fields.insert("workForm".to_string(), "standalone".to_string());
fields.insert("title".to_string(), track.title.clone());
fields.insert("album".to_string(), album.to_string());
if !artist.trim().is_empty() {
fields.insert("albumArtist".to_string(), artist.to_string());
fields.insert("artist".to_string(), artist.to_string());
fields.insert(format!("artists/{}", artist.trim()), "true".to_string());
}
if track.position > 0 {
fields.insert("trackNumber".to_string(), track.position.to_string());
}
// Matched on by the consumer to place a version against a track row.
fields.insert("mbRecordingId".to_string(), recording_mbid.to_string());
fields.insert("mbReleaseGroupId".to_string(), rg_mbid.to_string());
// The reference itself.
fields.insert(format!("cids/{yt_cid}"), "true".to_string());
// Which key produced the channel match. See `ChannelKey`.
fields.insert("ytMatchKey".to_string(), r.match_key.clone());
// Which fallback position this reference occupies for the recording,
// 0 = best. Several references per recording is the mitigation for
// reference rot (§3): the id is immutable but its *availability* is
// not, so the client needs a defined order to fall through.
fields.insert("ytRank".to_string(), rank.to_string());
// The canonical `<upstream_id>id` field every feeder record must carry.
fields.insert("youtubeid".to_string(), r.video_id.clone());
fields.insert("source/gateway:youtube".to_string(), "true".to_string());
// The cover — load-bearing, and silently fatal if omitted.
//
// The consumer's gate requires artwork (`gate::has_cover`), so a record
// without one is dropped before it ever reaches a version list, with no
// error anywhere. Emitted as a `url` LOCATOR (`0x1006`), which is the
// current convention: `cover_url` was REMOVED on 2026-08-21 because a
// `*_url` field only looks transient — the gateway rewrites it on
// success, so a 404 leaves the raw URL persisted forever.
//
// Note this is `cover`, not `poster`: music artwork has its own
// vocabulary since the two images come from different places
// (METADATA_KEYS.md §6).
if let Some(locator) = meta_feeder_sdk::hash::artwork_locator(&format!(
"{YT_THUMB_BASE}/{}/hqdefault.jpg",
r.video_id
)) {
fields.insert("cover".to_string(), locator);
}
echo_filters(&mut fields, query_filters);
Some(DiscoveryRecord {
upstream_id: "youtube".to_string(),
record_id: track_record_id(recording_mbid, &r.video_id),
fields,
})
}
}
/// Local copies of the two tiny query helpers `internetarchive` also uses.
/// Duplicated rather than shared because they are three lines each and the
/// alternative is a `pub` surface on a sibling plugin module.
fn wants_content_kind(query: &GatewayQuery, kind: &str) -> bool {
match query.filters.get("contentKind") {
None => true,
Some(values) if values.is_empty() => true,
Some(values) => values.iter().any(|v| v.trim().eq_ignore_ascii_case(kind)),
}
}
fn echo_filters(fields: &mut BTreeMap<String, String>, query_filters: &BTreeMap<String, Vec<String>>) {
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(","));
}
}
#[cfg(test)]
mod tests {
use super::*;
fn chan(id: &str) -> Vec<(String, String)> {
vec![(id.to_string(), ChannelKey::MbYoutubeMusic.as_str().to_string())]
}
fn cand(video: &str, title: &str, channel: &str, secs: i64) -> Candidate {
Candidate {
video_id: video.to_string(),
title: title.to_string(),
uploader_channel: channel.to_string(),
duration_secs: secs,
uploader: String::new(),
}
}
#[test]
fn exact_needs_channel_and_duration_and_title() {
// The real Daft Punk case from the study: MB "Get Lucky" at 370 s, the
// YouTube Music upload decorated with the feature credits.
let c = cand(
"4D7u5KF7SP8",
"Get Lucky (feat. Pharrell Williams and Nile Rodgers)",
"UCRr1xG_2WIDs18a6cIiCxeA",
370,
);
let (v, key) = grade(&c, "Get Lucky", Some(370), &chan("UCRr1xG_2WIDs18a6cIiCxeA"));
assert_eq!(v, Verdict::Exact);
assert_eq!(key.as_deref(), Some("mb:youtube-music"));
}
#[test]
fn a_different_channel_is_never_a_match() {
let c = cand("x", "Get Lucky", "UCsomeoneelse", 370);
let (v, _) = grade(&c, "Get Lucky", Some(370), &chan("UCRr1xG_2WIDs18a6cIiCxeA"));
assert_eq!(v, Verdict::NoMatch);
}
#[test]
fn channel_match_with_far_off_duration_is_a_refusal() {
// THE classical trap, with the study's measured deltas. MusicBrainz
// credits Bach for a Glenn Gould recording, so the channel agrees
// confidently and returns someone else's performance (+32 s, +209 s).
for delta in [32, 209] {
let c = cand("v", "The Goldberg Variations", "UCbach", 1000 + delta);
let (v, key) = grade(&c, "The Goldberg Variations", Some(1000), &chan("UCbach"));
assert_eq!(
v,
Verdict::ChannelWrongCut,
"a +{delta}s channel match must refuse, never widen into a near-miss"
);
// The key is still reported so the refusal is attributable.
assert!(key.is_some());
}
}
#[test]
fn a_track_musicbrainz_never_timed_cannot_reach_exact() {
// No length => nothing to compare => "the channel agreed" alone, which
// is exactly the shape the classical trap produces.
let c = cand("v", "Some Track", "UCartist", 200);
let (v, _) = grade(&c, "Some Track", None, &chan("UCartist"));
assert_eq!(v, Verdict::ChannelWrongCut);
}
#[test]
fn duration_alone_is_not_enough_the_title_must_agree() {
// Both real misses from the study's first run, both inside one second.
let burial = cand("v1", "Near Dark", "UCburial", 241);
let (v, _) = grade(&burial, "Archangel", Some(240), &chan("UCburial"));
assert_eq!(v, Verdict::ChannelWrongCut);
let adele = cand("v2", "Set Fire to the Rain", "UCadele", 242);
let (v, _) = grade(&adele, "Don't You Remember", Some(242), &chan("UCadele"));
assert_eq!(v, Verdict::ChannelWrongCut);
}
#[test]
fn title_normalisation_survives_musicbrainz_filing_conventions() {
// MusicBrainz's title is often not the title anyone says.
assert!(titles_agree(
"Lift Your Skinny Fists Like Antennas to Heaven",
"Lift Yr. Skinny Fists Like Antennas to Heaven!"
));
assert!(titles_agree("MAP OF THE SOUL : 7", "Map of the Soul: 7"));
// ...but it is not so loose that two different songs agree.
assert!(!titles_agree("Archangel", "Near Dark"));
assert!(!titles_agree("Don't You Remember", "Set Fire to the Rain"));
// The short-title guard: overlap must NOT rescue a 3-token near-miss,
// where one shared word is most of the title.
assert!(!titles_agree("Love Me Tender", "Love Me Do"));
}
#[test]
fn channel_and_video_ids_are_extracted_from_every_url_shape() {
assert_eq!(
channel_id_from_url("https://www.youtube.com/channel/UC_kRDKYrUlrbtrSiyu5Tflg").as_deref(),
Some("UC_kRDKYrUlrbtrSiyu5Tflg")
);
assert_eq!(
channel_id_from_url("https://music.youtube.com/channel/UCRr1xG_2WIDs18a6cIiCxeA")
.as_deref(),
Some("UCRr1xG_2WIDs18a6cIiCxeA")
);
assert_eq!(channel_id_from_url("/channel/UCabc?x=1").as_deref(), Some("UCabc"));
assert!(channel_id_from_url("https://youtube.com/user/foo").is_none());
assert_eq!(video_id_from_url("/watch?v=4D7u5KF7SP8").as_deref(), Some("4D7u5KF7SP8"));
assert_eq!(
video_id_from_url("/watch?v=4D7u5KF7SP8&list=OLAK").as_deref(),
Some("4D7u5KF7SP8")
);
}
#[test]
fn record_id_round_trips_and_mints_the_cid_with_no_io() {
let id = track_record_id("345e4a72-46b4-48ba-8541-27c6b23c3b8c", "4D7u5KF7SP8");
let (rec, vid) = split_track_record_id(&id).expect("round-trips");
assert_eq!(rec, "345e4a72-46b4-48ba-8541-27c6b23c3b8c");
assert_eq!(vid, "4D7u5KF7SP8");
// The same cid the golden vector pins.
assert_eq!(
meta_feeder_sdk::hash::compute_yt_video_cid("video", vid).unwrap(),
"bagecaaarav3gszdfn42ein3vgvfumn2tka4a"
);
assert!(split_track_record_id("no-separator").is_none());
assert!(split_track_record_id("rec~").is_none());
}
#[tokio::test]
async fn compute_outcomes_is_metadata_only() {
let p = YouTubePlugin::default();
let id = track_record_id("rec-mbid", "4D7u5KF7SP8");
let out = p.compute_outcomes(&id).await.expect("outcome");
assert_eq!(out.len(), 1);
assert_eq!(out[0].hash_kind, HashKind::YtVideo);
// The three properties that make this tier what it is.
assert!(out[0].bytes.is_none(), "delegated playback transports no bytes");
assert!(
out[0].file_extension.is_none(),
"file_extension MUST be None when bytes is None"
);
assert_eq!(out[0].hash.0, "bagecaaarav3gszdfn42ein3vgvfumn2tka4a");
}
#[tokio::test]
async fn handle_fetch_never_returns_a_stream() {
let p = YouTubePlugin::default();
assert!(p
.handle_fetch(&track_record_id("rec", "vid"))
.await
.expect("ok")
.is_none());
}
#[tokio::test]
async fn a_query_with_no_release_group_anchor_returns_nothing() {
// The mirror image of `internetarchive`: this plugin can ONLY work from
// an id anchor, because you cannot search YouTube for a UUID and an
// unverified match is the one output this tier must never produce.
let p = YouTubePlugin::default();
let q = GatewayQuery {
raw_text: "daft punk".to_string(),
free_text: "daft punk".to_string(),
filters: BTreeMap::new(),
ranges: Vec::new(),
negations: Vec::new(),
};
assert!(p.handle_query(&q, 20).await.expect("ok").is_empty());
}
// --- the raw video tier -------------------------------------------------
fn vq(free_text: &str, filters: &[(&str, &[&str])]) -> GatewayQuery {
GatewayQuery {
raw_text: free_text.to_string(),
free_text: free_text.to_string(),
filters: filters
.iter()
.map(|(k, vs)| {
(
(*k).to_string(),
vs.iter().map(|v| (*v).to_string()).collect::<Vec<_>>(),
)
})
.collect(),
ranges: Vec::new(),
negations: Vec::new(),
}
}
#[test]
fn file_type_request_is_explicit_not_permissive() {
// The asymmetry with `wants_content_kind` is the point: an absent axis
// means "no preference", and the raw tier must not read that as an
// invitation.
assert!(requests_file_type(
&vq("x", &[("fileType", &["video"])]),
"video"
));
assert!(requests_file_type(
&vq("x", &[("fileType", &["audio", "video"])]),
"video"
));
assert!(!requests_file_type(&vq("x", &[]), "video"));
assert!(!requests_file_type(
&vq("x", &[("fileType", &["audio"])]),
"video"
));
}
#[test]
fn musicbrainz_anchor_is_detected_on_every_id_axis() {
for k in ["mbReleaseGroupId", "mbArtistId", "mbRecordingId"] {
assert!(
has_musicbrainz_anchor(&vq("x", &[(k, &["8bfac288-ccc5-445d-9a7e-1a1"])])),
"{k} should count as an anchor"
);
}
assert!(!has_musicbrainz_anchor(&vq("x", &[("tmdbid", &["95479"])])));
// Present but empty is not an anchor — it cannot verify anything.
assert!(!has_musicbrainz_anchor(&vq("x", &[("mbArtistId", &[""])])));
}
#[test]
fn video_record_id_round_trips_through_the_shared_splitter() {
// `compute_outcomes` serves both tiers off this one parse; a raw id it
// cannot split would be a permanent error at hash time, long after the
// record looked fine.
let id = video_record_id("4D7u5KF7SP8");
let (ns, vid) = split_track_record_id(&id).expect("splits");
assert_eq!(ns, VIDEO_RECORD_NS);
assert_eq!(vid, "4D7u5KF7SP8");
}
#[tokio::test]
async fn compute_outcomes_serves_a_raw_record_id() {
let p = YouTubePlugin::default();
let out = p
.compute_outcomes(&video_record_id("4D7u5KF7SP8"))
.await
.expect("ok");
assert_eq!(out.len(), 1);
assert_eq!(
out[0].hash.0,
meta_feeder_sdk::hash::compute_yt_video_cid("video", "4D7u5KF7SP8").unwrap()
);
assert!(out[0].bytes.is_none(), "the tier has no byte path");
}
#[test]
fn raw_record_carries_the_fields_a_consumer_gates_on() {
let p = YouTubePlugin::default();
let c = Candidate {
video_id: "4D7u5KF7SP8".to_string(),
title: "Three-Body EP01".to_string(),
uploader_channel: "UCabc".to_string(),
duration_secs: 2712,
uploader: "Tencent Video".to_string(),
};
let filters: BTreeMap<String, Vec<String>> = [
("fileType".to_string(), vec!["video".to_string()]),
("tmdbid".to_string(), vec!["95479".to_string()]),
]
.into_iter()
.collect();
let r = p.video_record(&c, &filters).expect("record");
let f = &r.fields;
assert_eq!(f.get("fileType").map(String::as_str), Some("video"));
assert_eq!(f.get("youtubeid").map(String::as_str), Some("4D7u5KF7SP8"));
assert_eq!(f.get("ytMatchKey").map(String::as_str), Some(RAW_MATCH_KEY));
assert_eq!(f.get("fileinfo/duration").map(String::as_str), Some("2712"));
assert!(
f.contains_key("source/gateway:youtube"),
"raw bucket needs it"
);
assert!(f.contains_key("poster_url") && f.contains_key("description/eng"));
let cid = meta_feeder_sdk::hash::compute_yt_video_cid("video", "4D7u5KF7SP8").unwrap();
assert_eq!(
f.get(&format!("cids/{cid}")).map(String::as_str),
Some("true")
);
// The query's anchor is echoed…
assert_eq!(f.get("tmdbid").map(String::as_str), Some("95479"));
// …but never promoted into a trust claim. A raw hit that stamped
// `anchored` would leak into the enriched grid as if it had been
// verified, which is the whole failure this tier is shaped to avoid.
assert!(!f.contains_key("anchored"));
// No guessed contentKind — `pack` in particular would be dropped
// outright by meta-watch's raw projection.
assert!(!f.contains_key("contentKind"));
}
#[tokio::test]
async fn a_music_video_query_never_reaches_the_raw_path() {
// meta-listen's artist row is `mbArtistId:<uuid> fileType:video
// contentKind:musicVideo`. It requests video and would satisfy the raw
// branch's file-type test, so the ONLY things keeping it on the
// verified path are the dispatch order and the anchor guard. With no
// MusicBrainz client configured the verified path yields nothing —
// which is exactly how we can tell the two apart: a raw answer here
// would be non-empty.
let p = YouTubePlugin::default();
let q = vq(
"daft punk",
&[
("mbArtistId", &["056e4f3e-d505-4dad-8ec1-d04f521cbb56"]),
("fileType", &["video"]),
("contentKind", &["musicVideo"]),
],
);
assert!(p.handle_query(&q, 20).await.expect("ok").is_empty());
}
#[tokio::test]
async fn an_album_query_never_reaches_the_raw_path() {
// meta-listen's album query is `fileType:audio` — it must keep falling
// through to the MusicBrainz track path.
let p = YouTubePlugin::default();
let q = vq(
"daft punk discovery",
&[
(
"mbReleaseGroupId",
&["aa997ea0-2936-40bd-884d-3af8a0e064dc"],
),
("fileType", &["audio"]),
("contentKind", &["track"]),
],
);
assert!(p.handle_query(&q, 20).await.expect("ok").is_empty());
}
#[tokio::test]
async fn a_typed_video_query_with_no_free_text_returns_nothing() {
// A `tmdbid` alone cannot drive the raw path: there is no TMDB client
// here to turn the id into a searchable title, and searching for the
// literal number would be worse than refusing.
let p = YouTubePlugin::default();
let q = vq("", &[("fileType", &["video"]), ("tmdbid", &["95479"])]);
assert!(p.handle_query(&q, 20).await.expect("ok").is_empty());
}
#[tokio::test]
async fn a_typed_video_query_answers_from_a_free_text_search() {
use wiremock::matchers::{method, path, query_param};
use wiremock::{Mock, MockServer, ResponseTemplate};
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/search"))
.and(query_param("filter", "videos"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"items": [
{
"url": "/watch?v=4D7u5KF7SP8",
"title": "Three-Body EP01",
"uploaderUrl": "/channel/UCabc",
"uploaderName": "Tencent Video",
"duration": 2712
},
{
"url": "/watch?v=OLAK5uy_kZ8X",
"title": "Three-Body EP02",
"uploaderUrl": "/channel/UCabc",
"uploaderName": "Tencent Video",
"duration": 2680
}
]
})))
.mount(&server)
.await;
let p = YouTubePlugin::with_bases(server.uri(), server.uri());
let q = vq("three-body", &[("fileType", &["video"])]);
let out = p.handle_query(&q, 20).await.expect("ok");
assert_eq!(out.len(), 2, "both hits emitted, unverified");
assert_eq!(out[0].upstream_id, "youtube");
assert_eq!(out[0].record_id, video_record_id("4D7u5KF7SP8"));
assert_eq!(
out[0].fields.get("ytMatchKey").map(String::as_str),
Some(RAW_MATCH_KEY)
);
// No channel/duration filter ran — this is the point of the tier, and a
// regression that reintroduced one would silently empty it.
assert_eq!(
out[1].fields.get("title").map(String::as_str),
Some("Three-Body EP02")
);
}
#[tokio::test]
async fn the_raw_ceiling_bounds_one_query() {
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
// 30 distinct hits, an 11-char id each.
let items: Vec<_> = (0..30)
.map(|i| {
serde_json::json!({
"url": format!("/watch?v=vid{:08}", i),
"title": format!("hit {i}"),
"uploaderUrl": "/channel/UCabc",
"uploaderName": "Someone",
"duration": 100
})
})
.collect();
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/search"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"items": items
})))
.mount(&server)
.await;
let p = YouTubePlugin::with_bases(server.uri(), server.uri());
let q = vq("anything", &[("fileType", &["video"])]);
// `max_results` above the ceiling: the ceiling wins, because every hit
// costs the core a thumbnail fetch.
let out = p.handle_query(&q, 500).await.expect("ok");
assert_eq!(out.len(), YT_VIDEO_SEARCH_MAX);
// …and below it, the caller's own budget wins.
let out = p.handle_query(&q, 3).await.expect("ok");
assert_eq!(out.len(), 3);
}
fn tref(video: &str, delta: i64, key: &str) -> TrackRef {
TrackRef {
video_id: video.to_string(),
title: video.to_string(),
match_key: key.to_string(),
delta,
}
}
fn pick(recording: &str, refs: Vec<TrackRef>) -> TrackPick {
TrackPick {
recording_mbid: recording.to_string(),
track: musicbrainz_feeder::client::Track {
title: recording.to_string(),
..Default::default()
},
refs,
}
}
/// §7.5.1 — the collision rule, with the study's own measured case.
///
/// Burial's *Archangel* and *Near Dark* both resolved to `VVa_aCSA9Jc` at
/// EXACT: same channel, both durations inside tolerance. They are different
/// recordings, so at least one is wrong. The tighter delta keeps it and the
/// other drops the reference entirely — emitting both would put the same
/// audio under two names.
#[test]
fn two_recordings_never_share_one_video() {
let mut picks = vec![
pick("archangel", vec![tref("VVa_aCSA9Jc", 7, "mb:youtube-music")]),
pick("near-dark", vec![tref("VVa_aCSA9Jc", 1, "mb:youtube-music")]),
];
assign_references(&mut picks);
assert!(picks[0].refs.is_empty(), "the looser claim loses the video");
assert_eq!(picks[1].refs.len(), 1, "the tighter delta keeps it");
}
/// A collision must not cost a track its *other*, uncontested references.
#[test]
fn a_collision_only_drops_the_contested_reference() {
let mut picks = vec![
pick("a", vec![tref("shared", 9, "mb:youtube"), tref("own-a", 2, "mb:youtube")]),
pick("b", vec![tref("shared", 1, "mb:youtube")]),
];
assign_references(&mut picks);
assert_eq!(picks[0].refs.len(), 1);
assert_eq!(picks[0].refs[0].video_id, "own-a");
assert_eq!(picks[1].refs[0].video_id, "shared");
}
/// §3 — several ranked references per recording is the rot mitigation.
/// Uncontested ones all survive, in rank order.
#[test]
fn a_recording_keeps_several_ranked_references() {
let mut picks = vec![pick(
"rec",
vec![
tref("best", 0, "mb:youtube-music"),
tref("second", 3, "mb:youtube"),
tref("third", 8, "piped:music-artists"),
],
)];
assign_references(&mut picks);
assert_eq!(picks[0].refs.len(), 3);
assert_eq!(picks[0].refs[0].video_id, "best");
}
/// The tie-break behind that order: an equal duration delta is settled by
/// how authoritative the channel key is, never arbitrarily.
#[test]
fn channel_key_strength_breaks_a_duration_tie() {
assert!(key_priority("mb:youtube-music") < key_priority("mb:youtube"));
assert!(key_priority("mb:youtube") < key_priority("piped:music-artists"));
}
#[test]
fn the_cover_url_is_hqdefault_not_maxres() {
// `maxresdefault` 404s on anything not uploaded in HD, and that 404 is
// SILENT: the gate drops the record with no error anywhere.
let url = format!("{YT_THUMB_BASE}/4D7u5KF7SP8/hqdefault.jpg");
assert!(url.ends_with("/hqdefault.jpg"));
assert!(meta_feeder_sdk::hash::artwork_locator(&url).is_some());
}
}