132 lines
4.5 KiB
PHP
132 lines
4.5 KiB
PHP
<?php
|
|
/**
|
|
* api/lyrics.php
|
|
* --------------
|
|
* Optional lyrics lookup. Finds the song on Genius, then scrapes the lyric
|
|
* containers from the public song page. Returns JSON:
|
|
*
|
|
* { "found": true|false, "lyrics": "plain text\nwith newlines", "url": "…" }
|
|
*
|
|
* Lyrics are returned as plain text (tags stripped) so the front-end can render
|
|
* them with textContent — no HTML injection from a third-party page.
|
|
*
|
|
* Note: this scrapes Genius' HTML, which can change at any time. If Genius
|
|
* restructures their page the extraction may need updating.
|
|
*/
|
|
|
|
require_once __DIR__ . '/_bootstrap.php';
|
|
|
|
$empty = ['found' => false, 'lyrics' => '', 'url' => ''];
|
|
|
|
if (!cfg('lyrics.enabled') || cfg('genius.token') === '') {
|
|
json_out($empty);
|
|
}
|
|
|
|
[$artist, $title] = request_track();
|
|
if ($artist === '' && $title === '') {
|
|
json_out($empty);
|
|
}
|
|
|
|
$token = cfg('genius.token');
|
|
$timeout = 5;
|
|
$headers = ['Authorization: Bearer ' . $token];
|
|
|
|
/**
|
|
* Return the inner HTML of a balanced <div> whose opening tag starts at $lt.
|
|
* Correctly handles nested <div> elements.
|
|
*/
|
|
function inner_balanced_div($html, $lt) {
|
|
$open = strpos($html, '>', $lt);
|
|
if ($open === false) return '';
|
|
$i = $open + 1;
|
|
$start = $i;
|
|
$len = strlen($html);
|
|
$depth = 1;
|
|
while ($i < $len && $depth > 0) {
|
|
$next = strpos($html, '<', $i);
|
|
if ($next === false) break;
|
|
if (strncasecmp(substr($html, $next, 5), '</div', 5) === 0) {
|
|
$depth--;
|
|
if ($depth === 0) return substr($html, $start, $next - $start);
|
|
$i = $next + 5;
|
|
} elseif (strncasecmp(substr($html, $next, 4), '<div', 4) === 0) {
|
|
$depth++;
|
|
$i = $next + 4;
|
|
} else {
|
|
$i = $next + 1;
|
|
}
|
|
}
|
|
return substr($html, $start, max(0, $i - $start));
|
|
}
|
|
|
|
/** Turn one container's HTML into plain text with newlines. */
|
|
function container_to_text($fragment) {
|
|
// Line breaks -> newlines before stripping tags.
|
|
$fragment = preg_replace('#<br\s*/?>#i', "\n", $fragment);
|
|
// Drop any stray script/style.
|
|
$fragment = preg_replace('#<(script|style)\b[^>]*>.*?</\1>#is', '', $fragment);
|
|
// Block-level tags imply a line break.
|
|
$fragment = preg_replace('#</(p|div)>#i', "\n", $fragment);
|
|
$text = strip_tags($fragment);
|
|
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
return $text;
|
|
}
|
|
|
|
/* -- Find the song on Genius ----------------------------------------------- */
|
|
$query = rawurlencode(trim($artist . ' - ' . $title, ' -'));
|
|
$search = http_get('https://api.genius.com/search?q=' . $query, $timeout, $headers);
|
|
if ($search === null) json_out($empty);
|
|
|
|
$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', '');
|
|
|
|
// 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;
|
|
}
|
|
}
|
|
}
|
|
if ($pageUrl === '') json_out($empty);
|
|
|
|
/* -- Scrape the lyric containers ------------------------------------------- */
|
|
$page = http_get($pageUrl, $timeout);
|
|
if ($page === null) {
|
|
json_out(['found' => false, 'lyrics' => '', 'url' => $pageUrl]);
|
|
}
|
|
|
|
$marker = 'data-lyrics-container="true"';
|
|
$pieces = [];
|
|
$offset = 0;
|
|
while (($mpos = strpos($page, $marker, $offset)) !== false) {
|
|
// Backtrack to the start of this opening <div ...> tag.
|
|
$lt = strrpos(substr($page, 0, $mpos), '<div');
|
|
if ($lt === false) { $offset = $mpos + strlen($marker); continue; }
|
|
$inner = inner_balanced_div($page, $lt);
|
|
$text = trim(container_to_text($inner));
|
|
if ($text !== '') $pieces[] = $text;
|
|
$offset = $mpos + strlen($marker);
|
|
}
|
|
|
|
$lyrics = trim(implode("\n\n", $pieces));
|
|
// Trim stray indentation left on each line, then collapse blank-line runs.
|
|
$lyrics = implode("\n", array_map('trim', explode("\n", $lyrics)));
|
|
$lyrics = preg_replace("/\n{3,}/", "\n\n", $lyrics);
|
|
|
|
if ($lyrics === '') {
|
|
json_out(['found' => false, 'lyrics' => '', 'url' => $pageUrl]);
|
|
}
|
|
|
|
json_out(['found' => true, 'lyrics' => $lyrics, 'url' => $pageUrl]);
|