Reimplemented EEPROM and Twitter lookups.
This commit is contained in:
parent
7160e9909d
commit
4ad19c6363
17 changed files with 1078 additions and 402 deletions
|
@ -6,6 +6,12 @@ require_once __DIR__ . '/../uiharu.php';
|
|||
// should be in a cron job
|
||||
$db->execute('DELETE FROM `uih_metadata_cache` WHERE `metadata_created` < NOW() - INTERVAL 7 DAY');
|
||||
|
||||
$ctx->registerLookup(new \Uiharu\Lookup\EEPROMLookup('eeprom', 'eeprom.flashii.net', ['i.fii.moe', 'i.flashii.net']));
|
||||
if(UIH_DEBUG)
|
||||
$ctx->registerLookup(new \Uiharu\Lookup\EEPROMLookup('devrom', 'eeprom.edgii.net', ['i.edgii.net']));
|
||||
|
||||
$ctx->registerLookup(new \Uiharu\Lookup\TwitterLookup);
|
||||
|
||||
$ctx->setupHttp();
|
||||
|
||||
$ctx->registerApi(new \Uiharu\Apis\v1_0($ctx));
|
||||
|
|
|
@ -3,20 +3,30 @@ namespace Uiharu\APIs;
|
|||
|
||||
use stdClass;
|
||||
use DOMDocument;
|
||||
use Exception;
|
||||
use InvalidArgumentException;
|
||||
use Uiharu\Colour;
|
||||
use Uiharu\Config;
|
||||
use Uiharu\FFMPEG;
|
||||
use Uiharu\IHasMediaInfo;
|
||||
use Uiharu\MediaTypeExts;
|
||||
use Uiharu\UihContext;
|
||||
use Uiharu\Url;
|
||||
use Uiharu\Lookup\EEPROMLookupResult;
|
||||
use Uiharu\Lookup\TwitterLookupResult;
|
||||
use Uiharu\Lookup\TwitterLookupTweetResult;
|
||||
use Uiharu\Lookup\TwitterLookupUserResult;
|
||||
use Index\MediaType;
|
||||
use Index\Data\IDbConnection;
|
||||
use Index\Http\HttpFx;
|
||||
use Index\Performance\Stopwatch;
|
||||
|
||||
final class v1_0 implements \Uiharu\IApi {
|
||||
private UihContext $ctx;
|
||||
private IDbConnection $db;
|
||||
|
||||
public function __construct(UihContext $ctx) {
|
||||
$this->ctx = $ctx;
|
||||
$this->db = $ctx->getDatabase();
|
||||
}
|
||||
|
||||
|
@ -29,36 +39,6 @@ final class v1_0 implements \Uiharu\IApi {
|
|||
$router->post('/metadata', [$this, 'handlePOST']);
|
||||
}
|
||||
|
||||
public function eepromLookup(stdClass $resp, string $eepromFileId, string $domain = 'flashii'): void {
|
||||
$resp->type = 'eeprom:file';
|
||||
$resp->color = '#8559a5';
|
||||
$resp->eeprom_file_id = $eepromFileId;
|
||||
$curl = curl_init("https://eeprom.{$domain}.net/uploads/{$resp->eeprom_file_id}.json");
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_AUTOREFERER => false,
|
||||
CURLOPT_CERTINFO => false,
|
||||
CURLOPT_FAILONERROR => false,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TCP_FASTOPEN => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 2,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
CURLOPT_USERAGENT => 'Uiharu/' . UIH_VERSION,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Accept: application/json',
|
||||
],
|
||||
]);
|
||||
$eepromResp = curl_exec($curl);
|
||||
curl_close($curl);
|
||||
$resp->eeprom_file_info = json_decode($eepromResp);
|
||||
if(isset($resp->eeprom_file_info->name))
|
||||
$resp->title = $resp->eeprom_file_info->name;
|
||||
if(isset($resp->eeprom_file_info->thumb))
|
||||
$resp->image = $resp->eeprom_file_info->thumb;
|
||||
$resp->site_name = 'Flashii EEPROM';
|
||||
}
|
||||
|
||||
public function handleGET($response, $request) {
|
||||
if($request->getMethod() === 'HEAD') {
|
||||
$response->setTypeJson();
|
||||
|
@ -124,411 +104,378 @@ final class v1_0 implements \Uiharu\IApi {
|
|||
}
|
||||
|
||||
if(empty($resp->type)) {
|
||||
$urlScheme = strtolower($parsedUrl->getScheme());
|
||||
$urlHost = strtolower($parsedUrl->getHost());
|
||||
$urlPath = '/' . trim($parsedUrl->getPath(), '/');
|
||||
$lookup = $this->ctx->matchLookup($parsedUrl);
|
||||
|
||||
if($urlScheme === 'eeprom') {
|
||||
if(preg_match('#^([A-Za-z0-9-_]+)$#', $parsedUrl->getPath(), $matches)) {
|
||||
$parsedUrl = Url::parse('https://i.fii.moe/' . $matches[1]);
|
||||
$resp->uri = $parsedUrl->toV1();
|
||||
$continueRaw = true;
|
||||
$this->eepromLookup($resp, $matches[1]);
|
||||
}
|
||||
} elseif($urlScheme === 'devrom') {
|
||||
if(preg_match('#^([A-Za-z0-9-_]+)$#', $parsedUrl->getPath(), $matches)) {
|
||||
$parsedUrl = Url::parse('https://i.edgii.net/' . $matches[1]);
|
||||
$resp->uri = $parsedUrl->toV1();
|
||||
$continueRaw = true;
|
||||
$this->eepromLookup($resp, $matches[1], 'edgii');
|
||||
}
|
||||
} elseif($urlScheme === 'http' || $urlScheme === 'https') {
|
||||
switch($urlHost) {
|
||||
case 'i.flashii.net':
|
||||
case 'i.fii.moe':
|
||||
$eepromFileId = substr($urlPath, 1);
|
||||
case 'eeprom.flashii.net':
|
||||
if(!isset($eepromFileId) && preg_match('#^/uploads/([A-Za-z0-9-_]+)/?$#', $urlPath, $matches))
|
||||
$eepromFileId = $matches[1];
|
||||
if($lookup !== null) {
|
||||
try {
|
||||
$result = $lookup->lookup($parsedUrl);
|
||||
|
||||
if(!empty($eepromFileId)) {
|
||||
$continueRaw = true;
|
||||
$this->eepromLookup($resp, $eepromFileId);
|
||||
}
|
||||
break;
|
||||
$resp->uri = $result->getUrl()->toV1();
|
||||
$resp->type = $result->getObjectType();
|
||||
|
||||
case 'i.edgii.net':
|
||||
$eepromFileId = substr($urlPath, 1);
|
||||
case 'eeprom.edgii.net':
|
||||
if(!isset($eepromFileId) && preg_match('#^/uploads/([A-Za-z0-9-_]+)/?$#', $urlPath, $matches))
|
||||
$eepromFileId = $matches[1];
|
||||
if($result->hasMediaType())
|
||||
$resp->content_type = MediaTypeExts::toV1($result->getMediaType());
|
||||
if($result->hasColour())
|
||||
$resp->color = Colour::toHexString($result->getColour());
|
||||
if($result->hasTitle())
|
||||
$resp->title = $result->getTitle();
|
||||
if($result->hasSiteName())
|
||||
$resp->site_name = $result->getSiteName();
|
||||
if($result->hasDescription())
|
||||
$resp->description = $result->getDescription();
|
||||
if($result->hasPreviewImage())
|
||||
$resp->image = $result->getPreviewImage();
|
||||
|
||||
if(!empty($eepromFileId)) {
|
||||
$continueRaw = true;
|
||||
$this->eepromLookup($resp, $eepromFileId, 'edgii');
|
||||
}
|
||||
break;
|
||||
if($result instanceof TwitterLookupResult) {
|
||||
if($result instanceof TwitterLookupTweetResult)
|
||||
$resp->tweet_id = $result->getTwitterTweetId();
|
||||
|
||||
case 'twitter.com': case 'www.twitter.com':
|
||||
case 'm.twitter.com': case 'mobile.twitter.com':
|
||||
case 'nitter.net': case 'www.nitter.net':
|
||||
if(preg_match('#^/@?(?:[A-Za-z0-9_]{1,20})/status(?:es)?/([0-9]+)/?$#', $urlPath, $matches)) {
|
||||
$resp->type = 'twitter:tweet';
|
||||
$resp->color = '#1da1f2';
|
||||
$resp->tweet_id = strval($matches[1] ?? '0');
|
||||
$curl = curl_init("https://api.twitter.com/2/tweets?ids={$resp->tweet_id}&expansions=attachments.media_keys,author_id,entities.mentions.username,referenced_tweets.id,referenced_tweets.id.author_id&media.fields=height,width,media_key,preview_image_url,url,type&tweet.fields=attachments,conversation_id,text,source,possibly_sensitive,created_at&user.fields=id,name,profile_image_url,protected,username,verified");
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_AUTOREFERER => false,
|
||||
CURLOPT_CERTINFO => false,
|
||||
CURLOPT_FAILONERROR => false,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TCP_FASTOPEN => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 2,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
CURLOPT_USERAGENT => 'Uiharu/' . UIH_VERSION,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Bearer ' . Config::get('Twitter', 'apiToken'),
|
||||
'Accept: application/json',
|
||||
],
|
||||
]);
|
||||
$tweetResp = curl_exec($curl);
|
||||
curl_close($curl);
|
||||
$resp->tweet_info = json_decode($tweetResp);
|
||||
if(isset($resp->tweet_info->includes->users[0]->name))
|
||||
$resp->title = $resp->tweet_info->includes->users[0]->name;
|
||||
if(isset($resp->tweet_info->includes->users[0]->profile_image_url))
|
||||
$resp->image = $resp->tweet_info->includes->users[0]->profile_image_url;
|
||||
if(isset($resp->tweet_info->data[0]->text))
|
||||
$resp->description = $resp->tweet_info->data[0]->text;
|
||||
$resp->site_name = 'Twitter';
|
||||
break;
|
||||
if($result instanceof TwitterLookupUserResult)
|
||||
$resp->twitter_user_name = $result->getTwitterUserName();
|
||||
|
||||
if(UIH_DEBUG)
|
||||
$resp->dbg_twitter_info = $result->getTwitterResult();
|
||||
}
|
||||
|
||||
if($result instanceof IHasMediaInfo) {
|
||||
if($result->isMedia()) {
|
||||
$resp->is_image = $result->isImage();
|
||||
$resp->is_audio = $result->isAudio();
|
||||
$resp->is_video = $result->isVideo();
|
||||
|
||||
if($result->hasDimensions()) {
|
||||
$resp->width = $result->getWidth();
|
||||
$resp->height = $result->getHeight();
|
||||
}
|
||||
|
||||
$resp->media = new stdClass;
|
||||
$resp->media->confidence = $result->getConfidence();
|
||||
|
||||
if($result->hasAspectRatio())
|
||||
$resp->media->aspect_ratio = $result->getAspectRatio();
|
||||
if($result->hasDuration())
|
||||
$resp->media->duration = $result->getDuration();
|
||||
if($result->hasSize())
|
||||
$resp->media->size = $result->getSize();
|
||||
if($result->hasBitRate())
|
||||
$resp->media->bitrate = $result->getBitRate();
|
||||
}
|
||||
|
||||
if(preg_match('#^/@?([A-Za-z0-9_]{1,20})/?$#', $urlPath, $matches)) {
|
||||
$resp->type = 'twitter:user';
|
||||
$resp->color = '#1da1f2';
|
||||
$resp->twitter_user_name = strval($matches[1] ?? '');
|
||||
$curl = curl_init("https://api.twitter.com/2/users/by?usernames={$resp->twitter_user_name}&user.fields=description,entities,id,name,profile_image_url,protected,url,username,verified");
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_AUTOREFERER => false,
|
||||
CURLOPT_CERTINFO => false,
|
||||
CURLOPT_FAILONERROR => false,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TCP_FASTOPEN => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 2,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
CURLOPT_USERAGENT => 'Uiharu/' . UIH_VERSION,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Bearer ' . Config::get('Twitter', 'apiToken'),
|
||||
'Accept: application/json',
|
||||
],
|
||||
]);
|
||||
$twitUserResp = curl_exec($curl);
|
||||
curl_close($curl);
|
||||
$resp->twitter_user_info = json_decode($twitUserResp);
|
||||
if(isset($resp->twitter_user_info->data[0]->name))
|
||||
$resp->title = $resp->twitter_user_info->data[0]->name;
|
||||
if(isset($resp->twitter_user_info->data[0]->profile_image_url))
|
||||
$resp->image = $resp->twitter_user_info->data[0]->profile_image_url;
|
||||
if(isset($resp->twitter_user_info->data[0]->description))
|
||||
$resp->description = $resp->twitter_user_info->data[0]->description;
|
||||
$resp->site_name = 'Twitter';
|
||||
break;
|
||||
if($result instanceof EEPROMLookupResult) {
|
||||
$resp->eeprom_file_id = $result->getEEPROMId();
|
||||
$resp->eeprom_file_info = $result->getEEPROMInfo();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'youtu.be': case 'www.youtu.be': // www. doesn't work for this, but may as well cover it
|
||||
$youtubeVideoId = substr($urlPath, 1);
|
||||
case 'youtube.com': case 'www.youtube.com':
|
||||
case 'youtube-nocookie.com': case 'www.youtube-nocookie.com':
|
||||
parse_str($parsedUrl->getQuery(), $queryString);
|
||||
|
||||
if(!isset($youtubeVideoId) && $urlPath === '/watch')
|
||||
$youtubeVideoId = $queryString['v'] ?? null;
|
||||
|
||||
if(!empty($youtubeVideoId)) {
|
||||
$resp->type = 'youtube:video';
|
||||
$resp->color = '#f00';
|
||||
$resp->youtube_video_id = $youtubeVideoId;
|
||||
|
||||
if(isset($queryString['t']))
|
||||
$resp->youtube_start_time = $queryString['t'];
|
||||
if(isset($queryString['list']))
|
||||
$resp->youtube_playlist = $queryString['list'];
|
||||
if(isset($queryString['index']))
|
||||
$resp->youtube_playlist_index = $queryString['index'];
|
||||
|
||||
$curl = curl_init("https://www.googleapis.com/youtube/v3/videos?part=snippet%2CcontentDetails%2Cstatistics&id={$resp->youtube_video_id}&key=" . Config::get('Google', 'apiKey'));
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_AUTOREFERER => false,
|
||||
CURLOPT_CERTINFO => false,
|
||||
CURLOPT_FAILONERROR => false,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TCP_FASTOPEN => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 2,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
CURLOPT_USERAGENT => 'Uiharu/' . UIH_VERSION,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Accept: application/json',
|
||||
],
|
||||
]);
|
||||
$youtubeResp = curl_exec($curl);
|
||||
curl_close($curl);
|
||||
$resp->youtube_video_info = json_decode($youtubeResp);
|
||||
if(isset($resp->youtube_video_info->items[0]->snippet->title))
|
||||
$resp->title = $resp->youtube_video_info->items[0]->snippet->title;
|
||||
if(isset($resp->youtube_video_info->items[0]->snippet->thumbnails->medium->url))
|
||||
$resp->image = $resp->youtube_video_info->items[0]->snippet->thumbnails->medium->url;
|
||||
if(isset($resp->youtube_video_info->items[0]->snippet->description))
|
||||
$resp->description = $resp->youtube_video_info->items[0]->snippet->description;
|
||||
$resp->site_name = 'YouTube';
|
||||
}
|
||||
break;
|
||||
if(UIH_DEBUG && $result->hasMediaInfo())
|
||||
$resp->dbg_media_info = $result->getMediaInfo();
|
||||
}
|
||||
} catch(Exception $ex) {
|
||||
$resp->error = 'metadata:lookup';
|
||||
if(UIH_DEBUG) {
|
||||
$resp->dbg_msg = $ex->getMessage();
|
||||
$resp->dbg_ex = (string)$ex;
|
||||
}
|
||||
$response->setStatusCode(500);
|
||||
return $resp;
|
||||
}
|
||||
} else {
|
||||
$resp->error = 'metadata:scheme';
|
||||
$response->setStatusCode(400);
|
||||
return $resp;
|
||||
}
|
||||
$urlScheme = strtolower($parsedUrl->getScheme());
|
||||
$urlHost = strtolower($parsedUrl->getHost());
|
||||
$urlPath = '/' . trim($parsedUrl->getPath(), '/');
|
||||
|
||||
if((empty($resp->type) || isset($continueRaw)) && in_array($parsedUrl->getScheme(), ['http', 'https'])) {
|
||||
$curl = curl_init((string)$parsedUrl);
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_AUTOREFERER => true,
|
||||
CURLOPT_CERTINFO => false,
|
||||
CURLOPT_FAILONERROR => false,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 5,
|
||||
CURLOPT_PATH_AS_IS => true,
|
||||
CURLOPT_NOBODY => true,
|
||||
CURLOPT_HEADER => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TCP_FASTOPEN => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 2,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
|
||||
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
CURLOPT_DEFAULT_PROTOCOL => 'https',
|
||||
CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible) Uiharu/' . UIH_VERSION,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Accept: text/html,application/xhtml+xml',
|
||||
],
|
||||
]);
|
||||
$headers = curl_exec($curl);
|
||||
if($urlScheme === 'http' || $urlScheme === 'https') {
|
||||
switch($urlHost) {
|
||||
case 'youtu.be': case 'www.youtu.be': // www. doesn't work for this, but may as well cover it
|
||||
$youtubeVideoId = substr($urlPath, 1);
|
||||
case 'youtube.com': case 'www.youtube.com':
|
||||
case 'youtube-nocookie.com': case 'www.youtube-nocookie.com':
|
||||
parse_str($parsedUrl->getQuery(), $queryString);
|
||||
|
||||
if($headers === false) {
|
||||
$resp->error = 'metadata:timeout';
|
||||
$resp->errorMessage = curl_error($curl);
|
||||
} else {
|
||||
$headersRaw = explode("\r\n", trim($headers));
|
||||
$statusCode = 200;
|
||||
$headers = [];
|
||||
foreach($headersRaw as $header) {
|
||||
if(empty($header))
|
||||
continue;
|
||||
if(strpos($header, ':') === false) {
|
||||
$headParts = explode(' ', $header);
|
||||
if(isset($headParts[1]) && is_numeric($headParts[1]))
|
||||
$statusCode = (int)$headParts[1];
|
||||
$headers = [];
|
||||
continue;
|
||||
}
|
||||
$headerParts = explode(':', $header, 2);
|
||||
$headerParts[0] = mb_strtolower($headerParts[0]);
|
||||
if(isset($headers[$headerParts[0]]))
|
||||
$headers[$headerParts[0]] .= ', ' . trim($headerParts[1] ?? '');
|
||||
else
|
||||
$headers[$headerParts[0]] = trim($headerParts[1] ?? '');
|
||||
}
|
||||
if(!isset($youtubeVideoId) && $urlPath === '/watch')
|
||||
$youtubeVideoId = $queryString['v'] ?? null;
|
||||
|
||||
try {
|
||||
$contentType = MediaType::parse($headers['content-type'] ?? '');
|
||||
} catch(InvalidArgumentException $ex) {
|
||||
$contentType = MediaType::parse('application/octet-stream');
|
||||
}
|
||||
if(!empty($youtubeVideoId)) {
|
||||
$resp->type = 'youtube:video';
|
||||
$resp->color = '#f00';
|
||||
$resp->youtube_video_id = $youtubeVideoId;
|
||||
|
||||
$resp->content_type = MediaTypeExts::toV1($contentType);
|
||||
if(isset($queryString['t']))
|
||||
$resp->youtube_start_time = $queryString['t'];
|
||||
if(isset($queryString['list']))
|
||||
$resp->youtube_playlist = $queryString['list'];
|
||||
if(isset($queryString['index']))
|
||||
$resp->youtube_playlist_index = $queryString['index'];
|
||||
|
||||
$isHTML = $contentType->equals('text/html');
|
||||
$isXHTML = $contentType->equals('application/xhtml+xml');
|
||||
|
||||
if($isHTML || $isXHTML) {
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_NOBODY => false,
|
||||
CURLOPT_HEADER => false,
|
||||
]);
|
||||
|
||||
$body = curl_exec($curl);
|
||||
curl_close($curl);
|
||||
|
||||
$document = new DOMDocument;
|
||||
if($isXHTML) {
|
||||
$document->loadXML($body, LIBXML_NOERROR | LIBXML_NONET | LIBXML_NOWARNING);
|
||||
} else {
|
||||
$document->loadHTML($body, LIBXML_NOERROR | LIBXML_NONET | LIBXML_NOWARNING);
|
||||
foreach($document->childNodes as $child)
|
||||
if($child->nodeType === XML_PI_NODE) {
|
||||
$document->removeChild($child);
|
||||
break;
|
||||
}
|
||||
$document->encoding = $contentType->getCharset();
|
||||
}
|
||||
|
||||
$charSet = $document->encoding;
|
||||
|
||||
$resp->type = 'website';
|
||||
$resp->title = '';
|
||||
|
||||
$isMetaTitle = false;
|
||||
$titleTag = $document->getElementsByTagName('title');
|
||||
foreach($titleTag as $tag) {
|
||||
$resp->title = trim(mb_convert_encoding($tag->textContent, 'utf-8', $charSet));
|
||||
break;
|
||||
}
|
||||
|
||||
$metaTags = $document->getElementsByTagName('meta');
|
||||
foreach($metaTags as $tag) {
|
||||
$nameAttr = $tag->hasAttribute('name') ? $tag->getAttribute('name') : (
|
||||
$tag->hasAttribute('property') ? $tag->getAttribute('property') : ''
|
||||
);
|
||||
$valueAttr = $tag->hasAttribute('value') ? $tag->getAttribute('value') : (
|
||||
$tag->hasAttribute('content') ? $tag->getAttribute('content') : ''
|
||||
);
|
||||
$nameAttr = trim(mb_convert_encoding($nameAttr, 'utf-8', $charSet));
|
||||
$valueAttr = trim(mb_convert_encoding($valueAttr, 'utf-8', $charSet));
|
||||
if(empty($nameAttr) || empty($valueAttr))
|
||||
continue;
|
||||
|
||||
switch($nameAttr) {
|
||||
case 'og:title':
|
||||
case 'twitter:title':
|
||||
if(!$isMetaTitle) {
|
||||
$isMetaTitle = true;
|
||||
$resp->title = $valueAttr;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'description':
|
||||
case 'og:description':
|
||||
case 'twitter:description':
|
||||
if(!isset($resp->description))
|
||||
$resp->description = $valueAttr;
|
||||
break;
|
||||
|
||||
case 'og:site_name':
|
||||
$resp->site_name = $valueAttr;
|
||||
break;
|
||||
|
||||
case 'og:image':
|
||||
case 'twitter:image':
|
||||
$resp->image = $valueAttr;
|
||||
break;
|
||||
|
||||
case 'theme-color':
|
||||
$resp->color = $valueAttr;
|
||||
break;
|
||||
|
||||
case 'og:type':
|
||||
$resp->type = $valueAttr;
|
||||
break;
|
||||
$curl = curl_init("https://www.googleapis.com/youtube/v3/videos?part=snippet%2CcontentDetails%2Cstatistics&id={$resp->youtube_video_id}&key=" . Config::get('Google', 'apiKey'));
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_AUTOREFERER => false,
|
||||
CURLOPT_CERTINFO => false,
|
||||
CURLOPT_FAILONERROR => false,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TCP_FASTOPEN => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 2,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
CURLOPT_USERAGENT => 'Uiharu/' . UIH_VERSION,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Accept: application/json',
|
||||
],
|
||||
]);
|
||||
$youtubeResp = curl_exec($curl);
|
||||
curl_close($curl);
|
||||
$resp->youtube_video_info = json_decode($youtubeResp);
|
||||
if(isset($resp->youtube_video_info->items[0]->snippet->title))
|
||||
$resp->title = $resp->youtube_video_info->items[0]->snippet->title;
|
||||
if(isset($resp->youtube_video_info->items[0]->snippet->thumbnails->medium->url))
|
||||
$resp->image = $resp->youtube_video_info->items[0]->snippet->thumbnails->medium->url;
|
||||
if(isset($resp->youtube_video_info->items[0]->snippet->description))
|
||||
$resp->description = $resp->youtube_video_info->items[0]->snippet->description;
|
||||
$resp->site_name = 'YouTube';
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
$resp->error = 'metadata:scheme';
|
||||
$response->setStatusCode(400);
|
||||
return $resp;
|
||||
}
|
||||
|
||||
if((empty($resp->type) || isset($continueRaw)) && in_array($parsedUrl->getScheme(), ['http', 'https'])) {
|
||||
$curl = curl_init((string)$parsedUrl);
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_AUTOREFERER => true,
|
||||
CURLOPT_CERTINFO => false,
|
||||
CURLOPT_FAILONERROR => false,
|
||||
CURLOPT_FOLLOWLOCATION => true,
|
||||
CURLOPT_MAXREDIRS => 5,
|
||||
CURLOPT_PATH_AS_IS => true,
|
||||
CURLOPT_NOBODY => true,
|
||||
CURLOPT_HEADER => true,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TCP_FASTOPEN => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 2,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
|
||||
CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
CURLOPT_DEFAULT_PROTOCOL => 'https',
|
||||
CURLOPT_USERAGENT => 'Mozilla/5.0 (compatible) Uiharu/' . UIH_VERSION,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Accept: text/html,application/xhtml+xml',
|
||||
],
|
||||
]);
|
||||
$headers = curl_exec($curl);
|
||||
|
||||
if($headers === false) {
|
||||
$resp->error = 'metadata:timeout';
|
||||
$resp->errorMessage = curl_error($curl);
|
||||
} else {
|
||||
$resp->is_image = $isImage = $contentType->matchCategory('image');
|
||||
$resp->is_audio = $isAudio = $contentType->matchCategory('audio');
|
||||
$resp->is_video = $isVideo = $contentType->matchCategory('video');
|
||||
$headersRaw = explode("\r\n", trim($headers));
|
||||
$statusCode = 200;
|
||||
$headers = [];
|
||||
foreach($headersRaw as $header) {
|
||||
if(empty($header))
|
||||
continue;
|
||||
if(strpos($header, ':') === false) {
|
||||
$headParts = explode(' ', $header);
|
||||
if(isset($headParts[1]) && is_numeric($headParts[1]))
|
||||
$statusCode = (int)$headParts[1];
|
||||
$headers = [];
|
||||
continue;
|
||||
}
|
||||
$headerParts = explode(':', $header, 2);
|
||||
$headerParts[0] = mb_strtolower($headerParts[0]);
|
||||
if(isset($headers[$headerParts[0]]))
|
||||
$headers[$headerParts[0]] .= ', ' . trim($headerParts[1] ?? '');
|
||||
else
|
||||
$headers[$headerParts[0]] = trim($headerParts[1] ?? '');
|
||||
}
|
||||
|
||||
if($isImage || $isAudio || $isVideo) {
|
||||
try {
|
||||
$contentType = MediaType::parse($headers['content-type'] ?? '');
|
||||
} catch(InvalidArgumentException $ex) {
|
||||
$contentType = MediaType::parse('application/octet-stream');
|
||||
}
|
||||
|
||||
$resp->content_type = MediaTypeExts::toV1($contentType);
|
||||
|
||||
$isHTML = $contentType->equals('text/html');
|
||||
$isXHTML = $contentType->equals('application/xhtml+xml');
|
||||
|
||||
if($isHTML || $isXHTML) {
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_NOBODY => false,
|
||||
CURLOPT_HEADER => false,
|
||||
]);
|
||||
|
||||
$body = curl_exec($curl);
|
||||
curl_close($curl);
|
||||
$resp->media = new stdClass;
|
||||
$ffmpeg = json_decode(shell_exec(sprintf('ffprobe -show_streams -show_format -print_format json -v quiet -i %s', escapeshellarg((string)$parsedUrl))));
|
||||
|
||||
if(!empty($ffmpeg)) {
|
||||
if(!empty($ffmpeg->format)) {
|
||||
$resp->media->confidence = empty($ffmpeg->format->probe_score) ? 0 : (intval($ffmpeg->format->probe_score) / 100);
|
||||
if(!empty($ffmpeg->format->duration))
|
||||
$resp->media->duration = floatval($ffmpeg->format->duration);
|
||||
if(!empty($ffmpeg->format->size))
|
||||
$resp->media->size = intval($ffmpeg->format->size);
|
||||
if(!empty($ffmpeg->format->bit_rate))
|
||||
$resp->media->bitrate = intval($ffmpeg->format->bit_rate);
|
||||
|
||||
if($isVideo || $isImage) {
|
||||
if(!empty($ffmpeg->streams)) {
|
||||
foreach($ffmpeg->streams as $stream) {
|
||||
if(($stream->codec_type ?? null) !== 'video')
|
||||
continue;
|
||||
|
||||
$resp->width = intval($stream->coded_width ?? $stream->width ?? -1);
|
||||
$resp->height = intval($stream->coded_height ?? $stream->height ?? -1);
|
||||
|
||||
if(!empty($stream->display_aspect_ratio))
|
||||
$resp->media->aspect_ratio = $stream->display_aspect_ratio;
|
||||
|
||||
if($isImage)
|
||||
break;
|
||||
}
|
||||
}
|
||||
$document = new DOMDocument;
|
||||
if($isXHTML) {
|
||||
$document->loadXML($body, LIBXML_NOERROR | LIBXML_NONET | LIBXML_NOWARNING);
|
||||
} else {
|
||||
$document->loadHTML($body, LIBXML_NOERROR | LIBXML_NONET | LIBXML_NOWARNING);
|
||||
foreach($document->childNodes as $child)
|
||||
if($child->nodeType === XML_PI_NODE) {
|
||||
$document->removeChild($child);
|
||||
break;
|
||||
}
|
||||
$document->encoding = $contentType->getCharset();
|
||||
}
|
||||
|
||||
if($isAudio) {
|
||||
function eat_tags(stdClass $dest, stdClass $source): void {
|
||||
if(!empty($source->title) || !empty($source->TITLE))
|
||||
$dest->title = $source->title ?? $source->TITLE;
|
||||
if(!empty($source->artist) || !empty($source->ARTIST))
|
||||
$dest->artist = $source->artist ?? $source->ARTIST;
|
||||
if(!empty($source->album) || !empty($source->ALBUM))
|
||||
$dest->album = $source->album ?? $source->ALBUM;
|
||||
if(!empty($source->date) || !empty($source->DATE))
|
||||
$dest->date = $source->date ?? $source->DATE;
|
||||
if(!empty($source->comment) || !empty($source->COMMENT))
|
||||
$dest->comment = $source->comment ?? $source->COMMENT;
|
||||
if(!empty($source->genre) || !empty($source->GENRE))
|
||||
$dest->genre = $source->genre ?? $source->GENRE;
|
||||
$charSet = $document->encoding;
|
||||
|
||||
$resp->type = 'website';
|
||||
$resp->title = '';
|
||||
|
||||
$isMetaTitle = false;
|
||||
$titleTag = $document->getElementsByTagName('title');
|
||||
foreach($titleTag as $tag) {
|
||||
$resp->title = trim(mb_convert_encoding($tag->textContent, 'utf-8', $charSet));
|
||||
break;
|
||||
}
|
||||
|
||||
$metaTags = $document->getElementsByTagName('meta');
|
||||
foreach($metaTags as $tag) {
|
||||
$nameAttr = $tag->hasAttribute('name') ? $tag->getAttribute('name') : (
|
||||
$tag->hasAttribute('property') ? $tag->getAttribute('property') : ''
|
||||
);
|
||||
$valueAttr = $tag->hasAttribute('value') ? $tag->getAttribute('value') : (
|
||||
$tag->hasAttribute('content') ? $tag->getAttribute('content') : ''
|
||||
);
|
||||
$nameAttr = trim(mb_convert_encoding($nameAttr, 'utf-8', $charSet));
|
||||
$valueAttr = trim(mb_convert_encoding($valueAttr, 'utf-8', $charSet));
|
||||
if(empty($nameAttr) || empty($valueAttr))
|
||||
continue;
|
||||
|
||||
switch($nameAttr) {
|
||||
case 'og:title':
|
||||
case 'twitter:title':
|
||||
if(!$isMetaTitle) {
|
||||
$isMetaTitle = true;
|
||||
$resp->title = $valueAttr;
|
||||
}
|
||||
break;
|
||||
|
||||
if(!empty($ffmpeg->format->tags)) {
|
||||
$resp->media->tags = new stdClass;
|
||||
eat_tags($resp->media->tags, $ffmpeg->format->tags);
|
||||
} elseif(!empty($ffmpeg->streams)) {
|
||||
// iterate over streams, fuck ogg
|
||||
$resp->media->tags = new stdClass;
|
||||
foreach($ffmpeg->streams as $stream) {
|
||||
if(($stream->codec_type ?? null) === 'audio' && !empty($stream->tags)) {
|
||||
eat_tags($resp->media->tags, $stream->tags);
|
||||
if(!empty($resp->media->tags))
|
||||
case 'description':
|
||||
case 'og:description':
|
||||
case 'twitter:description':
|
||||
if(!isset($resp->description))
|
||||
$resp->description = $valueAttr;
|
||||
break;
|
||||
|
||||
case 'og:site_name':
|
||||
$resp->site_name = $valueAttr;
|
||||
break;
|
||||
|
||||
case 'og:image':
|
||||
case 'twitter:image':
|
||||
$resp->image = $valueAttr;
|
||||
break;
|
||||
|
||||
case 'theme-color':
|
||||
$resp->color = $valueAttr;
|
||||
break;
|
||||
|
||||
case 'og:type':
|
||||
$resp->type = $valueAttr;
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if(empty($resp->type))
|
||||
$resp->type = 'media';
|
||||
$resp->is_image = $isImage = $contentType->matchCategory('image');
|
||||
$resp->is_audio = $isAudio = $contentType->matchCategory('audio');
|
||||
$resp->is_video = $isVideo = $contentType->matchCategory('video');
|
||||
|
||||
if($isImage || $isAudio || $isVideo) {
|
||||
curl_close($curl);
|
||||
$resp->media = new stdClass;
|
||||
$ffmpeg = FFMPEG::probe($parsedUrl);
|
||||
|
||||
if(!empty($ffmpeg)) {
|
||||
if(!empty($ffmpeg->format)) {
|
||||
$resp->media->confidence = empty($ffmpeg->format->probe_score) ? 0 : (intval($ffmpeg->format->probe_score) / 100);
|
||||
if(!empty($ffmpeg->format->duration))
|
||||
$resp->media->duration = floatval($ffmpeg->format->duration);
|
||||
if(!empty($ffmpeg->format->size))
|
||||
$resp->media->size = intval($ffmpeg->format->size);
|
||||
if(!empty($ffmpeg->format->bit_rate))
|
||||
$resp->media->bitrate = intval($ffmpeg->format->bit_rate);
|
||||
|
||||
if($isVideo || $isImage) {
|
||||
if(!empty($ffmpeg->streams)) {
|
||||
foreach($ffmpeg->streams as $stream) {
|
||||
if(($stream->codec_type ?? null) !== 'video')
|
||||
continue;
|
||||
|
||||
$resp->width = intval($stream->coded_width ?? $stream->width ?? -1);
|
||||
$resp->height = intval($stream->coded_height ?? $stream->height ?? -1);
|
||||
|
||||
if(!empty($stream->display_aspect_ratio))
|
||||
$resp->media->aspect_ratio = $stream->display_aspect_ratio;
|
||||
|
||||
if($isImage)
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(empty($resp->title)) {
|
||||
$audioTitle = '';
|
||||
if(!empty($resp->media->tags->artist))
|
||||
$audioTitle .= $resp->media->tags->artist . ' - ';
|
||||
if(!empty($resp->media->tags->title))
|
||||
$audioTitle .= $resp->media->tags->title;
|
||||
if(!empty($resp->media->tags->date))
|
||||
$audioTitle .= ' (' . $resp->media->tags->date . ')';
|
||||
if(!empty($audioTitle))
|
||||
$resp->title = $audioTitle;
|
||||
}
|
||||
if($isAudio) {
|
||||
function eat_tags(stdClass $dest, stdClass $source): void {
|
||||
if(!empty($source->title) || !empty($source->TITLE))
|
||||
$dest->title = $source->title ?? $source->TITLE;
|
||||
if(!empty($source->artist) || !empty($source->ARTIST))
|
||||
$dest->artist = $source->artist ?? $source->ARTIST;
|
||||
if(!empty($source->album) || !empty($source->ALBUM))
|
||||
$dest->album = $source->album ?? $source->ALBUM;
|
||||
if(!empty($source->date) || !empty($source->DATE))
|
||||
$dest->date = $source->date ?? $source->DATE;
|
||||
if(!empty($source->comment) || !empty($source->COMMENT))
|
||||
$dest->comment = $source->comment ?? $source->COMMENT;
|
||||
if(!empty($source->genre) || !empty($source->GENRE))
|
||||
$dest->genre = $source->genre ?? $source->GENRE;
|
||||
}
|
||||
|
||||
if(empty($resp->description) && !empty($resp->media->tags->comment))
|
||||
$resp->description = $resp->media->tags->comment;
|
||||
if(!empty($ffmpeg->format->tags)) {
|
||||
$resp->media->tags = new stdClass;
|
||||
eat_tags($resp->media->tags, $ffmpeg->format->tags);
|
||||
} elseif(!empty($ffmpeg->streams)) {
|
||||
// iterate over streams, fuck ogg
|
||||
$resp->media->tags = new stdClass;
|
||||
foreach($ffmpeg->streams as $stream) {
|
||||
if(($stream->codec_type ?? null) === 'audio' && !empty($stream->tags)) {
|
||||
eat_tags($resp->media->tags, $stream->tags);
|
||||
if(!empty($resp->media->tags))
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(empty($resp->title)) {
|
||||
$audioTitle = '';
|
||||
if(!empty($resp->media->tags->artist))
|
||||
$audioTitle .= $resp->media->tags->artist . ' - ';
|
||||
if(!empty($resp->media->tags->title))
|
||||
$audioTitle .= $resp->media->tags->title;
|
||||
if(!empty($resp->media->tags->date))
|
||||
$audioTitle .= ' (' . $resp->media->tags->date . ')';
|
||||
if(!empty($audioTitle))
|
||||
$resp->title = $audioTitle;
|
||||
}
|
||||
|
||||
if(empty($resp->description) && !empty($resp->media->tags->comment))
|
||||
$resp->description = $resp->media->tags->comment;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if($includeRawResult)
|
||||
$resp->ffmpeg = $ffmpeg;
|
||||
} else curl_close($curl);
|
||||
if($includeRawResult)
|
||||
$resp->ffmpeg = $ffmpeg;
|
||||
} else curl_close($curl);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
66
src/AudioTags.php
Normal file
66
src/AudioTags.php
Normal file
|
@ -0,0 +1,66 @@
|
|||
<?php
|
||||
namespace Uiharu;
|
||||
|
||||
class AudioTags {
|
||||
public function __construct(
|
||||
private string $title = '',
|
||||
private string $artist = '',
|
||||
private string $album = '',
|
||||
private string $date = '',
|
||||
private string $comment = '',
|
||||
private string $genre = ''
|
||||
) {}
|
||||
|
||||
public function hasTitle(): bool {
|
||||
return $this->title !== '';
|
||||
}
|
||||
public function getTitle(): string {
|
||||
return $this->title;
|
||||
}
|
||||
|
||||
public function hasArtist(): bool {
|
||||
return $this->artist !== '';
|
||||
}
|
||||
public function getArtist(): string {
|
||||
return $this->artist;
|
||||
}
|
||||
|
||||
public function hasAlbum(): bool {
|
||||
return $this->album !== '';
|
||||
}
|
||||
public function getAlbum(): string {
|
||||
return $this->album;
|
||||
}
|
||||
|
||||
public function hasDate(): bool {
|
||||
return $this->date !== '';
|
||||
}
|
||||
public function getDate(): string {
|
||||
return $this->date;
|
||||
}
|
||||
|
||||
public function hasComment(): bool {
|
||||
return $this->comment !== '';
|
||||
}
|
||||
public function getComment(): string {
|
||||
return $this->comment;
|
||||
}
|
||||
|
||||
public function hasGenre(): bool {
|
||||
return $this->genre !== '';
|
||||
}
|
||||
public function getGenre(): string {
|
||||
return $this->genre;
|
||||
}
|
||||
|
||||
public static function fromMediaInfo(object $obj): AudioTags {
|
||||
return new AudioTags(
|
||||
$obj->tagTitle ?? '',
|
||||
$obj->tagArtist ?? '',
|
||||
$obj->tagAlbum ?? '',
|
||||
$obj->tagDate ?? '',
|
||||
$obj->tagComment ?? '',
|
||||
$obj->tagGenre ?? '',
|
||||
);
|
||||
}
|
||||
}
|
8
src/Colour.php
Normal file
8
src/Colour.php
Normal file
|
@ -0,0 +1,8 @@
|
|||
<?php
|
||||
namespace Uiharu;
|
||||
|
||||
final class Colour {
|
||||
public static function toHexString(int $colour): string {
|
||||
return '#' . str_pad(dechex($colour), 6, '0', STR_PAD_LEFT);
|
||||
}
|
||||
}
|
71
src/FFMPEG.php
Normal file
71
src/FFMPEG.php
Normal file
|
@ -0,0 +1,71 @@
|
|||
<?php
|
||||
namespace Uiharu;
|
||||
|
||||
use stdClass;
|
||||
|
||||
final class FFMPEG {
|
||||
public static function probe(string $url): ?object {
|
||||
return json_decode(
|
||||
shell_exec(
|
||||
sprintf(
|
||||
'ffprobe -show_streams -show_format -print_format json -v quiet -i %s',
|
||||
escapeshellarg($url)
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public static function cleanProbe(string $url): ?object {
|
||||
return self::cleanProbeResult(self::probe($url));
|
||||
}
|
||||
|
||||
public static function cleanProbeResult(?object $in): ?object {
|
||||
if($in === null)
|
||||
return null;
|
||||
$out = new stdClass;
|
||||
|
||||
if(!empty($in->format)) {
|
||||
$out->confidence = empty($in->format->probe_score) ? 0 : (intval($in->format->probe_score) / 100);
|
||||
|
||||
if(!empty($in->format->duration))
|
||||
$out->duration = floatval($in->format->duration);
|
||||
|
||||
if(!empty($in->format->size))
|
||||
$out->size = intval($in->format->size);
|
||||
|
||||
if(!empty($in->format->bit_rate))
|
||||
$out->bitRate = intval($int->format->bit_rate);
|
||||
|
||||
if(!empty($in->format->tags)) {
|
||||
$out->tagTitle = $in->format->tags->title ?? $in->format->tags->TITLE;
|
||||
$out->tagArtist = $in->format->tags->artist ?? $in->format->tags->ARTIST;
|
||||
$out->tagAlbum = $in->format->tags->album ?? $in->format->tags->ALBUM;
|
||||
$out->tagDate = $in->format->tags->date ?? $in->format->tags->DATE;
|
||||
$out->tagComment = $in->format->tags->comment ?? $in->format->tags->COMMENT;
|
||||
$out->tagGenre = $in->format->tags->genre ?? $in->format->tags->GENRE;
|
||||
}
|
||||
}
|
||||
|
||||
if(!empty($in->streams))
|
||||
foreach($in->streams as $stream) {
|
||||
$codecType = $stream->codec_type ?? null;
|
||||
|
||||
if($codecType === 'video') {
|
||||
$out->width = intval($stream->coded_width ?? $stream->width ?? -1);
|
||||
$out->height = intval($stream->coded_height ?? $stream->height ?? -1);
|
||||
|
||||
if(!empty($stream->display_aspect_ratio))
|
||||
$out->aspectRatio = $stream->display_aspect_ratio;
|
||||
} elseif($codecType === 'audio') {
|
||||
$out->tagTitle = $stream->tags->title ?? $stream->tags->TITLE;
|
||||
$out->tagArtist = $stream->tags->artist ?? $stream->tags->ARTIST;
|
||||
$out->tagAlbum = $stream->tags->album ?? $stream->tags->ALBUM;
|
||||
$out->tagDate = $stream->tags->date ?? $stream->tags->DATE;
|
||||
$out->tagComment = $stream->tags->comment ?? $stream->tags->COMMENT;
|
||||
$out->tagGenre = $stream->tags->genre ?? $stream->tags->GENRE;
|
||||
}
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
}
|
33
src/IHasMediaInfo.php
Normal file
33
src/IHasMediaInfo.php
Normal file
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
namespace Uiharu;
|
||||
|
||||
interface IHasMediaInfo extends ILookupResult {
|
||||
function getConfidence(): float;
|
||||
|
||||
function isMedia(): bool;
|
||||
function isImage(): bool;
|
||||
function isVideo(): bool;
|
||||
function isAudio(): bool;
|
||||
|
||||
function hasDimensions(): bool;
|
||||
function getWidth(): int;
|
||||
function getHeight(): int;
|
||||
|
||||
function hasAspectRatio(): bool;
|
||||
function getAspectRatio(): string;
|
||||
|
||||
function hasDuration(): bool;
|
||||
function getDuration(): float;
|
||||
|
||||
function hasSize(): bool;
|
||||
function getSize(): int;
|
||||
|
||||
function hasBitRate(): bool;
|
||||
function getBitRate(): int;
|
||||
|
||||
function hasAudioTags(): bool;
|
||||
function getAudioTags(): AudioTags;
|
||||
|
||||
function hasMediaInfo(): bool;
|
||||
function getMediaInfo(): object;
|
||||
}
|
7
src/ILookup.php
Normal file
7
src/ILookup.php
Normal file
|
@ -0,0 +1,7 @@
|
|||
<?php
|
||||
namespace Uiharu;
|
||||
|
||||
interface ILookup {
|
||||
function match(Url $url): bool;
|
||||
function lookup(Url $url): ILookupResult;
|
||||
}
|
27
src/ILookupResult.php
Normal file
27
src/ILookupResult.php
Normal file
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
namespace Uiharu;
|
||||
|
||||
use Index\MediaType;
|
||||
|
||||
interface ILookupResult {
|
||||
function getUrl(): Url;
|
||||
function getObjectType(): string;
|
||||
|
||||
function hasMediaType(): bool;
|
||||
function getMediaType(): MediaType;
|
||||
|
||||
function hasColour(): bool;
|
||||
function getColour(): int;
|
||||
|
||||
function hasTitle(): bool;
|
||||
function getTitle(): string;
|
||||
|
||||
function hasSiteName(): bool;
|
||||
function getSiteName(): string;
|
||||
|
||||
function hasDescription(): bool;
|
||||
function getDescription(): string;
|
||||
|
||||
function hasPreviewImage(): bool;
|
||||
function getPreviewImage(): string;
|
||||
}
|
77
src/Lookup/EEPROMLookup.php
Normal file
77
src/Lookup/EEPROMLookup.php
Normal file
|
@ -0,0 +1,77 @@
|
|||
<?php
|
||||
namespace Uiharu\Lookup;
|
||||
|
||||
use RuntimeException;
|
||||
use Uiharu\FFMPEG;
|
||||
use Uiharu\MediaTypeExts;
|
||||
use Uiharu\Url;
|
||||
use Index\MediaType;
|
||||
|
||||
final class EEPROMLookup implements \Uiharu\ILookup {
|
||||
public function __construct(
|
||||
private string $protocol,
|
||||
private string $apiDomain,
|
||||
private array $shortDomains
|
||||
) {}
|
||||
|
||||
public function match(Url $url): bool {
|
||||
return $url->getScheme() === $this->protocol || (
|
||||
$url->isWeb() && (
|
||||
in_array($url->getHost(), $this->shortDomains) || (
|
||||
$url->getHost() === $this->apiDomain && str_starts_with($url->getPath(), '/uploads')
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private function rawLookup(string $fileId): ?object {
|
||||
$curl = curl_init("https://{$this->apiDomain}/uploads/{$fileId}.json");
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_AUTOREFERER => false,
|
||||
CURLOPT_CERTINFO => false,
|
||||
CURLOPT_FAILONERROR => false,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TCP_FASTOPEN => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 2,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
CURLOPT_USERAGENT => 'Uiharu/' . UIH_VERSION,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Accept: application/json',
|
||||
],
|
||||
]);
|
||||
$resp = curl_exec($curl);
|
||||
curl_close($curl);
|
||||
return json_decode($resp);
|
||||
}
|
||||
|
||||
public function lookup(Url $url): EEPROMLookupResult {
|
||||
if($url->getScheme() === $this->protocol) {
|
||||
$fileId = $url->getPath();
|
||||
} elseif($url->isWeb()) {
|
||||
if($url->getHost() === $this->apiDomain) {
|
||||
if(preg_match('#^/uploads/([A-Za-z0-9-_]+)/?$#', $url->getPath(), $matches))
|
||||
$fileId = $matches[1];
|
||||
} else {
|
||||
$fileId = substr($url->getPath(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
if(!isset($fileId))
|
||||
throw new RuntimeException('Was unable to find EEPROM file id.');
|
||||
if(!preg_match('#^([A-Za-z0-9-_]+)$#', $fileId))
|
||||
throw new RuntimeException('Invalid EEPROM file id format.');
|
||||
|
||||
$fileInfo = $this->rawLookup($fileId);
|
||||
if($fileInfo === null)
|
||||
throw new RuntimeException('EEPROM file does not exist.');
|
||||
|
||||
$url = Url::parse('https://' . $this->shortDomains[0] . '/' . $fileId);
|
||||
$mediaType = MediaType::parse($fileInfo->type);
|
||||
$isMedia = MediaTypeExts::isMedia($mediaType);
|
||||
$mediaInfo = $isMedia ? FFMPEG::cleanProbe($url) : null;
|
||||
|
||||
return new EEPROMLookupResult($url, $mediaType, $fileInfo, $mediaInfo);
|
||||
}
|
||||
}
|
173
src/Lookup/EEPROMLookupResult.php
Normal file
173
src/Lookup/EEPROMLookupResult.php
Normal file
|
@ -0,0 +1,173 @@
|
|||
<?php
|
||||
namespace Uiharu\Lookup;
|
||||
|
||||
use Uiharu\AudioTags;
|
||||
use Uiharu\Url;
|
||||
use Index\MediaType;
|
||||
|
||||
final class EEPROMLookupResult implements \Uiharu\ILookupResult, \Uiharu\IHasMediaInfo {
|
||||
private Url $url;
|
||||
private MediaType $type;
|
||||
private object $fileInfo;
|
||||
private ?object $mediaInfo;
|
||||
private ?AudioTags $audioTags;
|
||||
|
||||
public function __construct(
|
||||
Url $url,
|
||||
MediaType $type,
|
||||
object $fileInfo,
|
||||
?object $mediaInfo
|
||||
) {
|
||||
$this->url = $url;
|
||||
$this->type = $type;
|
||||
$this->fileInfo = $fileInfo;
|
||||
$this->mediaInfo = $mediaInfo;
|
||||
|
||||
if($this->isAudio() && $mediaInfo !== null)
|
||||
$this->audioTags = AudioTags::fromMediaInfo($mediaInfo);
|
||||
else
|
||||
$this->audioTags = null;
|
||||
}
|
||||
|
||||
public function getUrl(): Url {
|
||||
return $this->url;
|
||||
}
|
||||
public function getObjectType(): string {
|
||||
return 'eeprom:file';
|
||||
}
|
||||
|
||||
public function hasMediaType(): bool {
|
||||
return true;
|
||||
}
|
||||
public function getMediaType(): MediaType {
|
||||
return $this->type;
|
||||
}
|
||||
|
||||
public function getEEPROMId(): string {
|
||||
return $this->fileInfo->id;
|
||||
}
|
||||
public function getEEPROMInfo(): object {
|
||||
return $this->fileInfo;
|
||||
}
|
||||
|
||||
public function hasColour(): bool {
|
||||
return true;
|
||||
}
|
||||
public function getColour(): int {
|
||||
return 0x8559A5;
|
||||
}
|
||||
|
||||
public function hasTitle(): bool {
|
||||
return true;
|
||||
}
|
||||
public function getTitle(): string {
|
||||
if($this->audioTags !== null) {
|
||||
$title = '';
|
||||
|
||||
if($this->audioTags->hasArtist())
|
||||
$title .= $this->audioTags->getArtist() . ' - ';
|
||||
|
||||
if($this->audioTags->hasTitle())
|
||||
$title .= $this->audioTags->getTitle();
|
||||
|
||||
if($this->audioTags->hasDate())
|
||||
$title .= ' (' . $this->audioTags->getDate() . ')';
|
||||
|
||||
$title = trim($title, " \n\r\t\v\x00()-");
|
||||
if(!empty($title))
|
||||
return $title;
|
||||
}
|
||||
|
||||
return $this->fileInfo->name;
|
||||
}
|
||||
|
||||
public function hasSiteName(): bool {
|
||||
return true;
|
||||
}
|
||||
public function getSiteName(): string {
|
||||
return 'EEPROM';
|
||||
}
|
||||
|
||||
public function hasDescription(): bool {
|
||||
return $this->audioTags !== null && $this->audioTags->hasComment();
|
||||
}
|
||||
public function getDescription(): string {
|
||||
return $this->audioTags->getComment();
|
||||
}
|
||||
|
||||
public function hasPreviewImage(): bool {
|
||||
return true;
|
||||
}
|
||||
public function getPreviewImage(): string {
|
||||
return $this->fileInfo->thumb;
|
||||
}
|
||||
|
||||
public function getConfidence(): float {
|
||||
return $this->mediaInfo->confidence ?? 0;
|
||||
}
|
||||
|
||||
public function isMedia(): bool {
|
||||
return $this->mediaInfo !== null;
|
||||
}
|
||||
public function isImage(): bool {
|
||||
return $this->type->matchCategory('image');
|
||||
}
|
||||
public function isVideo(): bool {
|
||||
return $this->type->matchCategory('video');
|
||||
}
|
||||
public function isAudio(): bool {
|
||||
return $this->type->matchCategory('audio');
|
||||
}
|
||||
|
||||
public function hasDimensions(): bool {
|
||||
return $this->isMedia();
|
||||
}
|
||||
public function getWidth(): int {
|
||||
return $this->mediaInfo->width ?? 0;
|
||||
}
|
||||
public function getHeight(): int {
|
||||
return $this->mediaInfo->height ?? 0;
|
||||
}
|
||||
|
||||
public function hasAspectRatio(): bool {
|
||||
return isset($this->mediaInfo->aspectRatio);
|
||||
}
|
||||
public function getAspectRatio(): string {
|
||||
return $this->mediaInfo->aspectRatio;
|
||||
}
|
||||
|
||||
public function hasDuration(): bool {
|
||||
return isset($this->mediaInfo->duration);
|
||||
}
|
||||
public function getDuration(): float {
|
||||
return $this->mediaInfo->duration;
|
||||
}
|
||||
|
||||
public function hasSize(): bool {
|
||||
return isset($this->mediaInfo->size);
|
||||
}
|
||||
public function getSize(): int {
|
||||
return $this->mediaInfo->size;
|
||||
}
|
||||
|
||||
public function hasBitRate(): bool {
|
||||
return isset($this->mediaInfo->bitRate);
|
||||
}
|
||||
public function getBitRate(): int {
|
||||
return $this->mediaInfo->bitRate;
|
||||
}
|
||||
|
||||
public function hasAudioTags(): bool {
|
||||
return $this->audioTags !== null;
|
||||
}
|
||||
public function getAudioTags(): AudioTags {
|
||||
return $this->audioTags;
|
||||
}
|
||||
|
||||
public function hasMediaInfo(): bool {
|
||||
return $this->mediaInfo !== null;
|
||||
}
|
||||
public function getMediaInfo(): object {
|
||||
return $this->mediaInfo;
|
||||
}
|
||||
}
|
89
src/Lookup/TwitterLookup.php
Normal file
89
src/Lookup/TwitterLookup.php
Normal file
|
@ -0,0 +1,89 @@
|
|||
<?php
|
||||
namespace Uiharu\Lookup;
|
||||
|
||||
use RuntimeException;
|
||||
use Uiharu\Config;
|
||||
use Uiharu\Url;
|
||||
use Index\MediaType;
|
||||
|
||||
final class TwitterLookup implements \Uiharu\ILookup {
|
||||
private const TWITTER_DOMAINS = [
|
||||
'twitter.com', 'www.twitter.com',
|
||||
'm.twitter.com', 'mobile.twitter.com',
|
||||
'nitter.net', 'www.nitter.net',
|
||||
];
|
||||
|
||||
public function match(Url $url): bool {
|
||||
if(!in_array($url->getHost(), self::TWITTER_DOMAINS))
|
||||
return false;
|
||||
|
||||
return preg_match('#^/@?(?:[A-Za-z0-9_]{1,20})/status(?:es)?/([0-9]+)/?$#', $url->getPath())
|
||||
|| preg_match('#^/@?([A-Za-z0-9_]{1,20})/?$#', $url->getPath());
|
||||
}
|
||||
|
||||
private function lookupUser(string $userName): ?object {
|
||||
$curl = curl_init("https://api.twitter.com/2/users/by?usernames={$userName}&user.fields=description,entities,id,name,profile_image_url,protected,url,username,verified");
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_AUTOREFERER => false,
|
||||
CURLOPT_CERTINFO => false,
|
||||
CURLOPT_FAILONERROR => false,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TCP_FASTOPEN => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 2,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
CURLOPT_USERAGENT => 'Uiharu/' . UIH_VERSION,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Bearer ' . Config::get('Twitter', 'apiToken'),
|
||||
'Accept: application/json',
|
||||
],
|
||||
]);
|
||||
$resp = curl_exec($curl);
|
||||
curl_close($curl);
|
||||
return json_decode($resp);
|
||||
}
|
||||
|
||||
private function lookupTweet(string $tweetId): ?object {
|
||||
$curl = curl_init("https://api.twitter.com/2/tweets?ids={$tweetId}&expansions=attachments.media_keys,author_id,entities.mentions.username,referenced_tweets.id,referenced_tweets.id.author_id&media.fields=height,width,media_key,preview_image_url,url,type&tweet.fields=attachments,conversation_id,text,source,possibly_sensitive,created_at&user.fields=id,name,profile_image_url,protected,username,verified");
|
||||
curl_setopt_array($curl, [
|
||||
CURLOPT_AUTOREFERER => false,
|
||||
CURLOPT_CERTINFO => false,
|
||||
CURLOPT_FAILONERROR => false,
|
||||
CURLOPT_FOLLOWLOCATION => false,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TCP_FASTOPEN => true,
|
||||
CURLOPT_CONNECTTIMEOUT => 2,
|
||||
CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
|
||||
CURLOPT_TIMEOUT => 5,
|
||||
CURLOPT_USERAGENT => 'Uiharu/' . UIH_VERSION,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Bearer ' . Config::get('Twitter', 'apiToken'),
|
||||
'Accept: application/json',
|
||||
],
|
||||
]);
|
||||
$resp = curl_exec($curl);
|
||||
curl_close($curl);
|
||||
return json_decode($resp);
|
||||
}
|
||||
|
||||
public function lookup(Url $url): TwitterLookupResult {
|
||||
if(preg_match('#^/@?(?:[A-Za-z0-9_]{1,20})/status(?:es)?/([0-9]+)/?$#', $url->getPath(), $matches)) {
|
||||
$tweetId = strval($matches[1] ?? '0');
|
||||
$tweetInfo = $this->lookupTweet($tweetId);
|
||||
if($tweetInfo === null)
|
||||
throw new RuntimeException('Tweet lookup failed.');
|
||||
return new TwitterLookupTweetResult($url, $tweetInfo);
|
||||
}
|
||||
|
||||
if(preg_match('#^/@?([A-Za-z0-9_]{1,20})/?$#', $url->getPath(), $matches)) {
|
||||
$userName = strval($matches[1] ?? '');
|
||||
$userInfo = $this->lookupUser($userName);
|
||||
if($userInfo === null)
|
||||
throw new RuntimeException('Twitter user lookup failed.');
|
||||
return new TwitterLookupUserResult($url, $userInfo);
|
||||
}
|
||||
|
||||
throw new RuntimeException('Unknown Twitter URL format.');
|
||||
}
|
||||
}
|
51
src/Lookup/TwitterLookupResult.php
Normal file
51
src/Lookup/TwitterLookupResult.php
Normal file
|
@ -0,0 +1,51 @@
|
|||
<?php
|
||||
namespace Uiharu\Lookup;
|
||||
|
||||
use RuntimeException;
|
||||
use Uiharu\Url;
|
||||
use Index\MediaType;
|
||||
|
||||
abstract class TwitterLookupResult implements \Uiharu\ILookupResult {
|
||||
private Url $url;
|
||||
|
||||
public function __construct(Url $url) {
|
||||
$this->url = $url;
|
||||
}
|
||||
|
||||
public function getUrl(): Url {
|
||||
return $this->url;
|
||||
}
|
||||
public abstract function getObjectType(): string;
|
||||
|
||||
public function hasMediaType(): bool {
|
||||
return false;
|
||||
}
|
||||
public function getMediaType(): MediaType {
|
||||
throw new RuntimeException('Unsupported');
|
||||
}
|
||||
|
||||
public function hasColour(): bool {
|
||||
return true;
|
||||
}
|
||||
public function getColour(): int {
|
||||
return 0x1DA1F2;
|
||||
}
|
||||
|
||||
public abstract function hasTitle(): bool;
|
||||
public abstract function getTitle(): string;
|
||||
|
||||
public function hasSiteName(): bool {
|
||||
return true;
|
||||
}
|
||||
public function getSiteName(): string {
|
||||
return 'Twitter';
|
||||
}
|
||||
|
||||
public abstract function hasDescription(): bool;
|
||||
public abstract function getDescription(): string;
|
||||
|
||||
public abstract function hasPreviewImage(): bool;
|
||||
public abstract function getPreviewImage(): string;
|
||||
|
||||
public abstract function getTwitterResult(): object;
|
||||
}
|
46
src/Lookup/TwitterLookupTweetResult.php
Normal file
46
src/Lookup/TwitterLookupTweetResult.php
Normal file
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
namespace Uiharu\Lookup;
|
||||
|
||||
use Uiharu\Url;
|
||||
|
||||
class TwitterLookupTweetResult extends TwitterLookupResult {
|
||||
private object $tweetInfo;
|
||||
|
||||
public function __construct(Url $url, object $tweetInfo) {
|
||||
parent::__construct($url);
|
||||
$this->tweetInfo = $tweetInfo;
|
||||
}
|
||||
|
||||
public function getObjectType(): string {
|
||||
return 'twitter:tweet';
|
||||
}
|
||||
|
||||
public function getTwitterTweetId(): string {
|
||||
return $this->tweetInfo->data[0]->id;
|
||||
}
|
||||
|
||||
public function hasTitle(): bool {
|
||||
return isset($this->tweetInfo->includes->users[0]->name);
|
||||
}
|
||||
public function getTitle(): string {
|
||||
return $this->tweetInfo->includes->users[0]->name;
|
||||
}
|
||||
|
||||
public function hasDescription(): bool {
|
||||
return isset($this->tweetInfo->data[0]->text);
|
||||
}
|
||||
public function getDescription(): string {
|
||||
return $this->tweetInfo->data[0]->text;
|
||||
}
|
||||
|
||||
public function hasPreviewImage(): bool {
|
||||
return isset($this->tweetInfo->includes->users[0]->profile_image_url);
|
||||
}
|
||||
public function getPreviewImage(): string {
|
||||
return $this->tweetInfo->includes->users[0]->profile_image_url;
|
||||
}
|
||||
|
||||
public function getTwitterResult(): object {
|
||||
return $this->tweetInfo;
|
||||
}
|
||||
}
|
46
src/Lookup/TwitterLookupUserResult.php
Normal file
46
src/Lookup/TwitterLookupUserResult.php
Normal file
|
@ -0,0 +1,46 @@
|
|||
<?php
|
||||
namespace Uiharu\Lookup;
|
||||
|
||||
use Uiharu\Url;
|
||||
|
||||
class TwitterLookupUserResult extends TwitterLookupResult {
|
||||
private object $userInfo;
|
||||
|
||||
public function __construct(Url $url, object $userInfo) {
|
||||
parent::__construct($url);
|
||||
$this->userInfo = $userInfo;
|
||||
}
|
||||
|
||||
public function getObjectType(): string {
|
||||
return 'twitter:user';
|
||||
}
|
||||
|
||||
public function getTwitterUserName(): string {
|
||||
return $this->userInfo->data[0]->username;
|
||||
}
|
||||
|
||||
public function hasTitle(): bool {
|
||||
return isset($this->userInfo->data[0]->name);
|
||||
}
|
||||
public function getTitle(): string {
|
||||
return $this->userInfo->data[0]->name;
|
||||
}
|
||||
|
||||
public function hasDescription(): bool {
|
||||
return isset($this->userInfo->data[0]->description);
|
||||
}
|
||||
public function getDescription(): string {
|
||||
return $this->userInfo->data[0]->description;
|
||||
}
|
||||
|
||||
public function hasPreviewImage(): bool {
|
||||
return isset($this->userInfo->data[0]->profile_image_url);
|
||||
}
|
||||
public function getPreviewImage(): string {
|
||||
return $this->userInfo->data[0]->profile_image_url;
|
||||
}
|
||||
|
||||
public function getTwitterResult(): object {
|
||||
return $this->userInfo;
|
||||
}
|
||||
}
|
|
@ -19,4 +19,10 @@ final class MediaTypeExts {
|
|||
|
||||
return $parts;
|
||||
}
|
||||
|
||||
public static function isMedia(MediaType $mediaType): bool {
|
||||
return $mediaType->matchCategory('image')
|
||||
|| $mediaType->matchCategory('audio')
|
||||
|| $mediaType->matchCategory('video');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@ final class UihContext {
|
|||
private IDbConnection $database;
|
||||
private HttpFx $router;
|
||||
private array $apis = [];
|
||||
private array $lookups = [];
|
||||
|
||||
public function __construct(IDbConnection $database) {
|
||||
$this->database = $database;
|
||||
|
@ -81,4 +82,15 @@ final class UihContext {
|
|||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public function registerLookup(ILookup $lookup): void {
|
||||
$this->lookups[] = $lookup;
|
||||
}
|
||||
|
||||
public function matchLookup(Url $url): ?ILookup {
|
||||
foreach($this->lookups as $lookup)
|
||||
if($lookup->match($url))
|
||||
return $lookup;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
13
src/Url.php
13
src/Url.php
|
@ -17,7 +17,7 @@ final class Url {
|
|||
|
||||
public function __construct(array $parts) {
|
||||
if(isset($parts['scheme']))
|
||||
$this->scheme = $parts['scheme'];
|
||||
$this->scheme = strtolower($parts['scheme']);
|
||||
if(isset($parts['host']))
|
||||
$this->host = $parts['host'];
|
||||
if(isset($parts['port']))
|
||||
|
@ -136,6 +136,17 @@ final class Url {
|
|||
return hash('sha256', (string)$this, $raw);
|
||||
}
|
||||
|
||||
public function isHTTP(): bool {
|
||||
return $this->scheme === 'http';
|
||||
}
|
||||
public function isHTTPS(): bool {
|
||||
return $this->scheme === 'https';
|
||||
}
|
||||
public function isWeb(): bool {
|
||||
return $this->scheme === 'https'
|
||||
|| $this->scheme === 'http';
|
||||
}
|
||||
|
||||
public function __toString(): string {
|
||||
if($this->formatted === null) {
|
||||
$string = '';
|
||||
|
|
Loading…
Reference in a new issue