236 lines
8.8 KiB
PHP
236 lines
8.8 KiB
PHP
<?php
|
|
/**
|
|
* api/_bootstrap.php
|
|
* ------------------
|
|
* Shared setup for every endpoint under /api: loads config, applies error
|
|
* settings, and provides small HTTP / JSON helpers.
|
|
*/
|
|
|
|
require_once __DIR__ . '/../config.php';
|
|
|
|
if (cfg('runtime.show_errors')) {
|
|
ini_set('display_errors', '1');
|
|
ini_set('display_startup_errors', '1');
|
|
error_reporting(E_ALL);
|
|
} else {
|
|
error_reporting(0);
|
|
}
|
|
|
|
/** Send a JSON response and stop. */
|
|
function json_out($data, $status = 200) {
|
|
http_response_code($status);
|
|
header('Content-Type: application/json; charset=utf-8');
|
|
header('Access-Control-Allow-Origin: *');
|
|
header('Cache-Control: no-cache, must-revalidate, max-age=0');
|
|
echo json_encode($data, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
|
|
exit;
|
|
}
|
|
|
|
/** Plain-text response and stop (used by the lyrics endpoint). */
|
|
function text_out($text, $status = 200) {
|
|
http_response_code($status);
|
|
header('Content-Type: text/plain; charset=utf-8');
|
|
header('Access-Control-Allow-Origin: *');
|
|
header('Cache-Control: no-cache, must-revalidate, max-age=0');
|
|
echo $text;
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Perform an HTTP GET and return the body (or null on failure).
|
|
* Falls back to file_get_contents when cURL is unavailable.
|
|
*/
|
|
function http_get($url, $timeout = 5, $headers = []) {
|
|
if ($url === '') return null;
|
|
|
|
if (function_exists('curl_init')) {
|
|
$ch = curl_init($url);
|
|
curl_setopt_array($ch, [
|
|
CURLOPT_RETURNTRANSFER => true,
|
|
CURLOPT_CONNECTTIMEOUT => (int) $timeout,
|
|
CURLOPT_TIMEOUT => (int) $timeout,
|
|
CURLOPT_FOLLOWLOCATION => true,
|
|
CURLOPT_MAXREDIRS => 3,
|
|
CURLOPT_USERAGENT => cfg('runtime.user_agent'),
|
|
]);
|
|
if (!empty($headers)) {
|
|
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
|
}
|
|
$body = curl_exec($ch);
|
|
$code = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
|
|
curl_close($ch);
|
|
if ($body === false || $code >= 400) return null;
|
|
return $body;
|
|
}
|
|
|
|
// Fallback without cURL.
|
|
$ctx = stream_context_create([
|
|
'http' => [
|
|
'timeout' => (int) $timeout,
|
|
'header' => implode("\r\n", array_merge(['User-Agent: ' . cfg('runtime.user_agent')], $headers)),
|
|
'ignore_errors' => true,
|
|
],
|
|
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
|
|
]);
|
|
$body = @file_get_contents($url, false, $ctx);
|
|
return ($body === false) ? null : $body;
|
|
}
|
|
|
|
/**
|
|
* Read a value out of a decoded-JSON structure using dot notation.
|
|
* Supports array indexes: "a.b.0.c".
|
|
*/
|
|
function dot_get($data, $path, $default = null) {
|
|
if ($path === '' || $path === null) return $default;
|
|
$node = $data;
|
|
foreach (explode('.', $path) as $key) {
|
|
if (is_array($node) && array_key_exists($key, $node)) {
|
|
$node = $node[$key];
|
|
} elseif (is_object($node) && isset($node->$key)) {
|
|
$node = $node->$key;
|
|
} else {
|
|
return $default;
|
|
}
|
|
}
|
|
return $node;
|
|
}
|
|
|
|
/** Read the artist/title the browser sends to artwork/lyrics endpoints. */
|
|
function request_track() {
|
|
$artist = $_POST['artist'] ?? $_GET['artist'] ?? '';
|
|
$title = $_POST['title'] ?? $_GET['title'] ?? '';
|
|
return [trim((string) $artist), trim((string) $title)];
|
|
}
|
|
|
|
/**
|
|
* Return the inner HTML of a balanced <div> whose opening tag starts at $lt.
|
|
* Correctly handles nested <div> elements. Shared by the Genius and dumb
|
|
* lyrics scrapers (both extract a lyrics container out of a raw HTML page).
|
|
*/
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* dumb (https://github.com/rramiachraf/dumb): a self-hosted/public Genius
|
|
* frontend that server-renders search + song pages. When USE_DUMB is on,
|
|
* these replace the official Genius API as the lyrics/artwork source (no
|
|
* GENIUS_TOKEN needed) — same idea as the Genius HTML scrape elsewhere in
|
|
* this codebase, just against dumb's own pages instead of genius.com.
|
|
* Fragile by nature (scrapes another project's HTML): if dumb changes its
|
|
* 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. */
|
|
function dumb_search($artist, $title, $timeout) {
|
|
$base = cfg('dumb.url');
|
|
if ($base === '') return '';
|
|
$query = rawurlencode(trim($artist . ' - ' . $title, ' -'));
|
|
$html = http_get($base . '/search?q=' . $query, $timeout);
|
|
if ($html === null) return '';
|
|
if (!preg_match('/<h2>\s*Songs\s*<\/h2>.*?<a[^>]+id="search-item"[^>]+href="([^"]+)"/is', $html, $m)) return '';
|
|
return html_entity_decode($m[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
}
|
|
|
|
/**
|
|
* Extract Genius annotation ("referent") links out of a raw lyrics HTML
|
|
* fragment. Returns an ordered list of ['text' => plain annotated lyric
|
|
* text, 'path' => dumb path to fetch the annotation JSON from]. Matched
|
|
* structurally — href = "/{id}/{song-slug}/{verse-slug}" — rather than by
|
|
* CSS class, since Genius's class names are build-hashed (styled-components)
|
|
* and change on their end without notice.
|
|
*/
|
|
function dumb_extract_annotations($fragment) {
|
|
$out = [];
|
|
if (!preg_match_all('#<a\s+href="(/\d+/[^"/]+/[^"/]+)"[^>]*>(.*?)</a>#is', $fragment, $matches, PREG_SET_ORDER)) {
|
|
return $out;
|
|
}
|
|
foreach ($matches as $m) {
|
|
$text = trim(container_to_text($m[2]));
|
|
if ($text === '') continue;
|
|
$out[] = ['text' => $text, 'path' => $m[1] . '/annotations'];
|
|
}
|
|
return $out;
|
|
}
|
|
|
|
/**
|
|
* Fetch + parse a dumb song page ($path from dumb_search()). Returns
|
|
* ['lyrics' => plain text, 'annotations' => [{text, path}, …] (see
|
|
* dumb_extract_annotations), 'album' => name, 'art' => absolute album art
|
|
* url, 'song_art' => absolute song thumbnail url] (any field may be ''/[]),
|
|
* or null if the page couldn't be fetched.
|
|
*/
|
|
function dumb_song($path, $timeout) {
|
|
$base = cfg('dumb.url');
|
|
if ($base === '' || $path === '') return null;
|
|
$html = http_get($base . $path, $timeout);
|
|
if ($html === null) return null;
|
|
|
|
$out = ['lyrics' => '', 'annotations' => [], 'album' => '', 'art' => '', 'song_art' => ''];
|
|
|
|
// <div id="lyrics"> wraps the raw Genius lyrics HTML that dumb already
|
|
// extracted server-side (no data-lyrics-container markers left in it).
|
|
$idPos = strpos($html, 'id="lyrics"');
|
|
if ($idPos !== false) {
|
|
$lt = strrpos(substr($html, 0, $idPos), '<div');
|
|
if ($lt !== false) {
|
|
$fragment = inner_balanced_div($html, $lt);
|
|
$out['annotations'] = dumb_extract_annotations($fragment);
|
|
$out['lyrics'] = trim(container_to_text($fragment));
|
|
}
|
|
}
|
|
|
|
// Two <img id="album-artwork"> tags can appear: the song thumbnail
|
|
// (#metadata, always present) and the album cover (#lyrics-album-
|
|
// container, only when the song belongs to an album — marked with a
|
|
// title="Album: …" attribute so the two are distinguishable).
|
|
if (preg_match_all('/<img\b[^>]*>/i', $html, $imgs)) {
|
|
foreach ($imgs[0] as $img) {
|
|
if (strpos($img, 'id="album-artwork"') === false) continue;
|
|
if (!preg_match('/\bsrc="([^"]+)"/i', $img, $src)) continue;
|
|
$url = $base . html_entity_decode($src[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
if (preg_match('/\btitle="Album:\s*([^"]*)"/i', $img, $t)) {
|
|
$out['album'] = html_entity_decode($t[1], ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
$out['art'] = $url;
|
|
} elseif ($out['song_art'] === '') {
|
|
$out['song_art'] = $url;
|
|
}
|
|
}
|
|
}
|
|
|
|
return $out;
|
|
}
|