diff --git a/crates/metadata/src/lib.rs b/crates/metadata/src/lib.rs index c9ba319..833aa1b 100644 --- a/crates/metadata/src/lib.rs +++ b/crates/metadata/src/lib.rs @@ -1985,6 +1985,13 @@ impl MetadataClient { } let title = result.title; let purl = result.page_url; + // Before identity, not after: reading the unfiltered bucket + // puts explicit results in the pool, and the identity gate + // is a relevance test with no notion of decency. + if is_adult_result(&title, &purl, &murl) { + rejected_unsuitable += 1; + continue; + } let Some(identity) = bing_identity_confidence(&title, &purl, &murl, artist) else { rejected_identity += 1; @@ -8007,7 +8014,16 @@ fn multi_artist_result_mismatches_image(title: &str, image_url: &str, artist: &s || listing_text.contains(" featuring ") || listing_text.contains(" vs. ") || listing_text.contains(" versus ") - || listing_text.contains(" x ") + // `" x "` is deliberately NOT here. It reads as a co-bill between two + // acts, but in practice it far more often joins a venue and a festival + // or a brand: "Nikki Nair | Boiler Room x Bass Coast 2022". Because + // every YouTube thumbnail is named `maxresdefault.jpg`, + // `image_names_artist` is structurally false for the whole class, so + // this one separator was rejecting every Boiler Room and festival set + // photo -- the richest source of hero-shaped photography that exists + // for DJs, and the only 1280x720 candidate Nikki Nair had. A genuine + // collaboration graphic is still caught below by `named_collaboration`, + // which requires the image itself to name the other subject. || two_act_conjunction || ([" y ", " con ", " junto a ", " et ", " und "] .iter() @@ -8053,6 +8069,59 @@ fn multi_artist_result_mismatches_image(title: &str, image_url: &str, artist: &s separator_count >= 2 && !image_names_artist } +/// Explicit-content rejection, applied BEFORE the identity gate. +/// +/// The hero search reads Bing's unfiltered bucket as well as the strict one, +/// because either can be serving another query's results. That is necessary for +/// coverage, but it means pornography now enters the candidate pool: a direct +/// read of one artist's unfiltered wave returned 24 hardcore cards. An audit +/// found zero of ~67 such cards could clear `bing_identity_confidence` -- porn +/// pages do not name obscure musicians -- but that is an empirical accident, +/// not a guarantee, and it is one gate deep. This is the independent second +/// layer that reading only the strict bucket used to provide. +fn is_adult_result(title: &str, page_url: &str, image_url: &str) -> bool { + let urls = format!("{page_url} {image_url}").to_lowercase(); + const ADULT_HOSTS: [&str; 16] = [ + "pornhub.", + "xhamster.", + "xhcdn.com", + "xnxx.", + "xvideos.", + "eporner.", + "redtube.", + "youporn.", + "spankbang.", + "pictoa.", + "thefappening", + "onlyfans.", + "fapello.", + "hentai-foundry.", + "rule34.", + "e-hentai.", + ]; + if ADULT_HOSTS.iter().any(|host| urls.contains(host)) { + return true; + } + // Title vocabulary, kept to terms that do not appear in music writing. + // "Adult contemporary", "XXX" as a roman numeral and band names such as + // Nude Beach or Pornography (The Cure) must NOT trip this, so match only + // unambiguous phrases. + let haystack = format!(" {} ", title.to_lowercase()); + const ADULT_PHRASES: [&str; 10] = [ + " porn ", + " porno ", + "hardcore porn", + " nsfw ", + " blowjob", + " creampie", + " anal sex", + " sex tape", + "nude leaks", + " milf ", + ]; + ADULT_PHRASES.iter().any(|term| haystack.contains(term)) +} + /// True for Bing results that are unambiguously merchandise, a physical music /// format, or a shop listing rather than a photograph of the performer. Keep /// this conservative: page/image URL structure is the strongest signal, while