diff --git a/api/_bootstrap.php b/api/_bootstrap.php index e197b36..7c8dac7 100644 --- a/api/_bootstrap.php +++ b/api/_bootstrap.php @@ -102,6 +102,46 @@ function request_track() { return [trim((string) $artist), trim((string) $title)]; } +/** + * Strip a featuring-artist credit ("(feat. X)", "(ft. X)", "(featuring X)", + * "(with X)", bracket variants, or an unparenthesized trailing "feat. X") + * before it goes into a Genius/dumb search query. + * + * Genius's canonical song title never includes the featuring credit (it's + * separate metadata), but translation/cover pages often spell it out + * verbatim in their title — e.g. searching "Havana (feat. Young Thug)" + * surfaces only German/French translation pages (titled "... ft. Young + * Thug ... (Deutsche Übersetzung)" etc.), while "Havana" alone correctly + * finds the original as the first hit. Leaves other qualifiers like + * "(Remix)"/"(Live)" alone — those genuinely change which page you want. + */ +function strip_feature_credit($title) { + $title = preg_replace('/[\(\[]\s*(?:feat\.?|ft\.?|featuring|with)\b[^\)\]]*[\)\]]/i', '', $title); + $title = preg_replace('/\s+(?:feat\.?|ft\.?|featuring)\b.+$/i', '', $title); + return trim(preg_replace('/\s{2,}/', ' ', $title)); +} + +/** + * Loose "is this search hit actually by the artist we want" check — + * case-insensitive, ignores punctuation/spacing and anything in brackets + * (so "Camila Cabello (Ft. Young Thug)" still matches "Camila Cabello"). + * An empty side always matches (nothing to compare against). Used to skip + * past hits attributed to someone else entirely — most notably Genius's + * "Genius Deutsche Übersetzungen" / "Genius traductions françaises" / etc. + * pseudo-artist accounts that translation pages get filed under. + */ +function artist_loosely_matches($hitArtist, $wantArtist) { + $norm = function ($s) { + $s = preg_replace('/[\(\[][^\)\]]*[\)\]]/', '', (string) $s); + $s = preg_replace('/[^\p{L}\p{N}]+/u', '', $s); + return strtolower($s); + }; + $a = $norm($hitArtist); + $b = $norm($wantArtist); + if ($a === '' || $b === '') return true; + return strpos($a, $b) !== false || strpos($b, $a) !== false; +} + /** * Return the inner HTML of a balanced
whose opening tag starts at $lt. * Correctly handles nested
elements. Shared by the Genius and dumb @@ -154,15 +194,36 @@ function container_to_text($fragment) { * templates, these regexes may need updating. */ -/** Search dumb for a track; returns the first song result's page path (e.g. "/Artist-title-lyrics"), or '' if none. */ +/** + * Search dumb for a track; returns a song result's page path (e.g. + * "/Artist-title-lyrics"), or '' if none. Scans the first 5 song hits for + * one actually attributed to $artist (see artist_loosely_matches) and + * returns that; falls back to the top hit if none match rather than + * returning nothing, so an unusual artist-name spelling can't regress + * availability entirely. + */ function dumb_search($artist, $title, $timeout) { $base = cfg('dumb.url'); if ($base === '') return ''; - $query = rawurlencode(trim($artist . ' - ' . $title, ' -')); + $query = rawurlencode(trim($artist . ' - ' . strip_feature_credit($title), ' -')); $html = http_get($base . '/search?q=' . $query, $timeout); if ($html === null) return ''; - if (!preg_match('/

\s*Songs\s*<\/h2>.*?]+id="search-item"[^>]+href="([^"]+)"/is', $html, $m)) return ''; - return html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'); + + if (!preg_match('/

\s*Songs\s*<\/h2>(.*?)(?=

|$)/is', $html, $section)) return ''; + if (!preg_match_all('/]+id="search-item"[^>]+href="([^"]+)"[^>]*>.*?([^<]*)<\/span>/is', $section[1], $hits, PREG_SET_ORDER)) { + return ''; + } + + $fallback = ''; + $checked = 0; + foreach ($hits as $h) { + $href = html_entity_decode($h[1], ENT_QUOTES | ENT_HTML5, 'UTF-8'); + if ($fallback === '') $fallback = $href; + if ($checked++ >= 5) break; + $hitArtist = html_entity_decode($h[2], ENT_QUOTES | ENT_HTML5, 'UTF-8'); + if (artist_loosely_matches($hitArtist, $artist)) return $href; + } + return $fallback; } /** diff --git a/api/artwork.php b/api/artwork.php index e0ab207..18520cd 100644 --- a/api/artwork.php +++ b/api/artwork.php @@ -85,11 +85,24 @@ function genius_artwork($artist, $title) { if ($token === '') return null; $headers = ['Authorization: Bearer ' . $token]; - $query = rawurlencode(trim($artist . ' - ' . $title, ' -')); + $query = rawurlencode(trim($artist . ' - ' . strip_feature_credit($title), ' -')); $search = http_get('https://api.genius.com/search?q=' . $query, 5, $headers); if ($search === null) return null; - $hit = dot_get(json_decode($search, true), 'response.hits.0.result', null); + $hits = dot_get(json_decode($search, true), 'response.hits', []); + if (!is_array($hits) || count($hits) === 0) return null; + + // Same artist-attribution scan as api/lyrics.php — skip translation-page + // hits filed under a pseudo-artist account, falling back to the raw top + // hit if nothing in the first 5 matches. + $hit = null; + $checked = 0; + foreach ($hits as $h) { + if ($checked++ >= 5) break; + $r = dot_get($h, 'result', null); + if ($r && artist_loosely_matches((string) ($r['artist_names'] ?? ''), $artist)) { $hit = $r; break; } + } + if ($hit === null) $hit = dot_get($hits, '0.result', null); if (!$hit) return null; $out = [ diff --git a/api/lyrics.php b/api/lyrics.php index e488597..70756f7 100644 --- a/api/lyrics.php +++ b/api/lyrics.php @@ -112,7 +112,7 @@ $token = cfg('genius.token'); $headers = ['Authorization: Bearer ' . $token]; /* -- Find the song on Genius ----------------------------------------------- */ -$query = rawurlencode(trim($artist . ' - ' . $title, ' -')); +$query = rawurlencode(trim($artist . ' - ' . strip_feature_credit($title), ' -')); $search = http_get('https://api.genius.com/search?q=' . $query, $timeout, $headers); if ($search === null) json_out($empty); @@ -120,23 +120,33 @@ $json = json_decode($search, true); $hits = dot_get($json, 'response.hits', []); if (!is_array($hits) || count($hits) === 0) json_out($empty); -$pageUrl = (string) dot_get($hits, '0.result.url', ''); +// Scan the first 5 hits for one actually attributed to $artist — skips +// past translation pages, which Genius files under pseudo-artist accounts +// like "Genius Deutsche Übersetzungen" rather than the real artist. Falls +// back to the raw top hit if nothing matches, so an unusual artist-name +// spelling can't regress availability entirely. When prefer_english is on, +// keep scanning matching hits until one is confirmed English. +$pageUrl = ''; +$fallbackUrl = (string) dot_get($hits, '0.result.url', ''); +$checked = 0; +foreach ($hits as $h) { + if ($checked++ >= 5) break; + if (!artist_loosely_matches((string) dot_get($h, 'result.artist_names', ''), $artist)) continue; -// Optionally prefer the first English-language match (a few extra API calls). -if (cfg('lyrics.prefer_english')) { - $checked = 0; - foreach ($hits as $h) { - if ($checked++ >= 5) break; - $id = dot_get($h, 'result.id', null); - if (!$id) continue; - $songRaw = http_get('https://api.genius.com/songs/' . rawurlencode((string) $id), $timeout, $headers); - if ($songRaw === null) continue; - if (dot_get(json_decode($songRaw, true), 'response.song.language', '') === 'en') { - $pageUrl = (string) dot_get($h, 'result.url', $pageUrl); - break; - } + $hitUrl = (string) dot_get($h, 'result.url', ''); + if ($pageUrl === '') $pageUrl = $hitUrl; + if (!cfg('lyrics.prefer_english')) break; + + $id = dot_get($h, 'result.id', null); + if (!$id) continue; + $songRaw = http_get('https://api.genius.com/songs/' . rawurlencode((string) $id), $timeout, $headers); + if ($songRaw === null) continue; + if (dot_get(json_decode($songRaw, true), 'response.song.language', '') === 'en') { + $pageUrl = $hitUrl; + break; } } +if ($pageUrl === '') $pageUrl = $fallbackUrl; if ($pageUrl === '') json_out($empty); /* -- Scrape the lyric containers ------------------------------------------- */