From a8f3800621429f75946674aeff0f79f2b41d4d4d Mon Sep 17 00:00:00 2001 From: Michael Date: Fri, 3 Oct 2025 03:04:41 +0000 Subject: [PATCH] Embedding Support for Twitter videos / automatic height adjust --- database.sql | 6 +- doc/database/db_post-media.md | 4 + src/Content/Post/Entity/PostMedia.php | 30 +++- src/Content/Post/Factory/PostMedia.php | 12 ++ src/Content/Post/Repository/PostMedia.php | 56 ++++-- src/Content/Text/BBCode.php | 13 +- src/Model/Item.php | 4 + src/Model/Post/Media.php | 4 + src/Protocol/ActivityPub/Receiver.php | 13 +- src/Util/ParseUrl.php | 161 ++++++++++++------ static/dbstructure.config.php | 6 +- static/defaults.config.php | 10 +- tests/src/Content/Text/BBCodeTest.php | 33 +++- .../templates/content/embed-iframe-resize.tpl | 25 +++ view/templates/content/embed-iframe.tpl | 7 + view/templates/content/iframe.tpl | 9 + 16 files changed, 308 insertions(+), 85 deletions(-) create mode 100644 view/templates/content/embed-iframe-resize.tpl create mode 100644 view/templates/content/embed-iframe.tpl create mode 100644 view/templates/content/iframe.tpl diff --git a/database.sql b/database.sql index 16a030696d..45cb0c7274 100644 --- a/database.sql +++ b/database.sql @@ -1,6 +1,6 @@ -- ------------------------------------------ -- Friendica 2025.07-rc (Interrupted Fern) --- DB_UPDATE_VERSION 1581 +-- DB_UPDATE_VERSION 1582 -- ------------------------------------------ @@ -1432,6 +1432,10 @@ CREATE TABLE IF NOT EXISTS `post-media` ( `player-url` varbinary(383) COMMENT 'URL of the embedded player for this media', `player-height` smallint unsigned COMMENT 'Height of the embedded player', `player-width` smallint unsigned COMMENT 'Width of the embedded player', + `embed-type` varchar(10) COMMENT 'Type of the embed (e.g. rich or video)', + `embed-html` text COMMENT 'HTML embed code for this media', + `embed-height` smallint unsigned COMMENT 'Height of the embed', + `embed-width` smallint unsigned COMMENT 'Width of the embed', `language` char(3) COMMENT 'Language information about this media in the ISO 639 format', `published` datetime COMMENT 'Publification date of this media', `modified` datetime COMMENT 'Modification date of this media', diff --git a/doc/database/db_post-media.md b/doc/database/db_post-media.md index 66abde57f3..85ae290806 100644 --- a/doc/database/db_post-media.md +++ b/doc/database/db_post-media.md @@ -33,6 +33,10 @@ Fields | player-url | URL of the embedded player for this media | varbinary(383) | YES | | NULL | | | player-height | Height of the embedded player | smallint unsigned | YES | | NULL | | | player-width | Width of the embedded player | smallint unsigned | YES | | NULL | | +| embed-type | Type of the embed (e.g. rich or video) | varchar(10) | YES | | NULL | | +| embed-html | HTML embed code for this media | text | YES | | NULL | | +| embed-height | Height of the embed | smallint unsigned | YES | | NULL | | +| embed-width | Width of the embed | smallint unsigned | YES | | NULL | | | language | Language information about this media in the ISO 639 format | char(3) | YES | | NULL | | | published | Publification date of this media | datetime | YES | | NULL | | | modified | Modification date of this media | datetime | YES | | NULL | | diff --git a/src/Content/Post/Entity/PostMedia.php b/src/Content/Post/Entity/PostMedia.php index bd864d67b8..1d4b9c09e2 100644 --- a/src/Content/Post/Entity/PostMedia.php +++ b/src/Content/Post/Entity/PostMedia.php @@ -38,6 +38,10 @@ use Psr\Http\Message\UriInterface; * @property-read ?UriInterface $playerUrl * @property-read ?int $playerWidth * @property-read ?int $playerHeight + * @property-read ?string $embedType + * @property-read ?string $embedHtml + * @property-read ?int $embedWidth + * @property-read ?int $embedHeight * @property-read ?int $attachId * @property-read ?string $language * @property-read ?string $published @@ -119,6 +123,14 @@ class PostMedia extends BaseEntity protected $published; /** @var ?string (Datetime) */ protected $modified; + /** @var ?string */ + protected $embedType; + /** @var ?string */ + protected $embedHtml; + /** @var ?int In pixels */ + protected $embedWidth; + /** @var ?int In pixels */ + protected $embedHeight; public function __construct( int $uriId, @@ -148,7 +160,11 @@ class PostMedia extends BaseEntity ?int $attachId = null, ?string $language = null, ?string $published = null, - ?string $modified = null + ?string $modified = null, + ?string $embedType = null, + ?string $embedHtml = null, + ?int $embedWidth = null, + ?int $embedHeight = null ) { $this->uriId = $uriId; $this->url = $url; @@ -178,6 +194,10 @@ class PostMedia extends BaseEntity $this->language = $language; $this->published = $published; $this->modified = $modified; + $this->embedType = $embedType; + $this->embedHtml = $embedHtml; + $this->embedWidth = $embedWidth; + $this->embedHeight = $embedHeight; } @@ -290,6 +310,10 @@ class PostMedia extends BaseEntity $this->language, $this->published, $this->modified, + $this->embedType, + $this->embedHtml, + $this->embedWidth, + $this->embedHeight ); } @@ -324,6 +348,10 @@ class PostMedia extends BaseEntity $this->language, $this->published, $this->modified, + $this->embedType, + $this->embedHtml, + $this->embedWidth, + $this->embedHeight ); } diff --git a/src/Content/Post/Factory/PostMedia.php b/src/Content/Post/Factory/PostMedia.php index 53b682f45a..af45a5c8a3 100644 --- a/src/Content/Post/Factory/PostMedia.php +++ b/src/Content/Post/Factory/PostMedia.php @@ -65,6 +65,10 @@ class PostMedia extends BaseFactory implements ICanCreateFromTableRow $row['language'], $row['published'], $row['modified'], + $row['embed-type'], + $row['embed-html'], + $row['embed-width'], + $row['embed-height'] ); } @@ -133,6 +137,10 @@ class PostMedia extends BaseFactory implements ICanCreateFromTableRow 'player-url' => $attachment['player_url'] ?? null, 'player-width' => $attachment['player_width'] ?? null, 'player-height' => $attachment['player_height'] ?? null, + 'embed-type' => $attachment['embed_type'] ?? null, + 'embed-html' => $attachment['embed_html'] ?? null, + 'embed-width' => $attachment['embed_width'] ?? null, + 'embed-height' => $attachment['embed_height'] ?? null, 'attach-id' => null, 'language' => null, 'published' => null, @@ -178,6 +186,10 @@ class PostMedia extends BaseFactory implements ICanCreateFromTableRow 'player-url' => $data['player']['embed'] ?? null, 'player-width' => $data['player']['width'] ?? null, 'player-height' => $data['player']['height'] ?? null, + 'embed-type' => $data['embed']['type'] ?? null, + 'embed-html' => $data['embed']['html'] ?? null, + 'embed-width' => $data['embed']['width'] ?? null, + 'embed-height' => $data['embed']['height'] ?? null, 'attach-id' => null, 'language' => $data['language'] ?? null, 'published' => $data['published'] ?? null, diff --git a/src/Content/Post/Repository/PostMedia.php b/src/Content/Post/Repository/PostMedia.php index eb8a4fe8b5..73c7aa8680 100644 --- a/src/Content/Post/Repository/PostMedia.php +++ b/src/Content/Post/Repository/PostMedia.php @@ -146,6 +146,10 @@ class PostMedia extends BaseRepository 'player-url' => $PostMedia->playerUrl, 'player-height' => $PostMedia->playerHeight, 'player-width' => $PostMedia->playerWidth, + 'embed-type' => $PostMedia->embedType, + 'embed-html' => $PostMedia->embedHtml, + 'embed-height' => $PostMedia->embedHeight, + 'embed-width' => $PostMedia->embedWidth, 'attach-id' => $PostMedia->attachId, 'language' => $PostMedia->language, 'published' => $PostMedia->published, @@ -365,6 +369,8 @@ class PostMedia extends BaseRepository $player = $this->getVideoAttachment($media, $uid); } elseif ($allow_embed && !empty($media->playerUrl)) { $player = $this->getPlayerIframe($media); + } elseif ($allow_embed && !empty($media->embedHtml)) { + $player = '' . $this->getEmbedIframe($media) . ''; } else { $player = $this->getLinkAttachment($media); } @@ -395,7 +401,7 @@ class PostMedia extends BaseRepository } if (($postMedia->height ?? 0) > ($postMedia->width ?? 0)) { - $height = min($this->config->get('system', 'max_video_height') ?: '100%', $postMedia->height); + $height = min($this->config->get('system', 'max_height') ?: '100%', $postMedia->height); $width = 'auto'; } else { $height = 'auto'; @@ -404,6 +410,8 @@ class PostMedia extends BaseRepository if ($this->pConfig->get($uid, 'system', 'embed_media', false) && ($postMedia->playerUrl != '') && ($postMedia->playerHeight > 0)) { $media = $this->getPlayerIframe($postMedia); + } elseif ($this->pConfig->get($uid, 'system', 'embed_media', false) && ($postMedia->embedHtml != '')) { + $media = $this->getEmbedIframe($postMedia); } else { /// @todo Move the template to /content as well $media = Renderer::replaceMacros(Renderer::getMarkupTemplate($postMedia->type == Post\Media::HLS ? 'hls_top.tpl' : 'video_top.tpl'), [ @@ -428,26 +436,38 @@ class PostMedia extends BaseRepository return ''; } - $attributes = ' src="' . $postMedia->playerUrl . '"'; - $max_height = $this->config->get('system', 'max_video_height') ?: $postMedia->playerHeight; + $div_style = ''; + $iframe_style = ''; + $height = min($this->config->get('system', 'max_height'), $postMedia->playerHeight); - if ($postMedia->playerWidth != 0 && $postMedia->playerHeight != 0) { - if ($postMedia->playerHeight > $postMedia->playerWidth) { - $factor = 100; - $height_attr = min($max_height, $postMedia->playerHeight); - } else { - $factor = round($postMedia->playerHeight / $postMedia->playerWidth, 2) * 100; - $height_attr = '100%'; - } - $attributes .= ' height="' . $height_attr. '" style="position:absolute;left:0px;top:0px"'; - $return = '
'; - } else { - $attributes .= ' height="' . min($max_height, $postMedia->playerHeight) . '"'; - $return = '
'; + if ($postMedia->playerWidth != 0 && $postMedia->playerHeight != 0 && $postMedia->playerWidth > $this->config->get('system', 'max_width') && $postMedia->playerWidth > $postMedia->playerHeight) { + $factor = round($postMedia->playerHeight / $postMedia->playerWidth, 2) * 100; + $height = '100%'; + $iframe_style .= 'position:absolute;left:0px;top:0px;'; + $div_style .= 'position:relative;padding-bottom:' . $factor . '%;'; } - $return .= '
'; - return $return; + return Renderer::replaceMacros(Renderer::getMarkupTemplate('content/iframe.tpl'), [ + 'src' => $postMedia->playerUrl, + 'height' => $height, + 'width' => $postMedia->embedWidth && $postMedia->embedWidth <= $this->config->get('system', 'max_width') ? $postMedia->embedWidth : '100%', + 'div_style' => $div_style, + 'iframe_style' => $iframe_style, + ]); + } + + public function getEmbedIframe(PostMediaEntity $postMedia): string + { + if ($postMedia->embedHtml == '') { + return ''; + } + + return Renderer::replaceMacros(Renderer::getMarkupTemplate($postMedia->embedHeight ? 'content/embed-iframe.tpl' : 'content/embed-iframe-resize.tpl'), [ + 'id' => 'iframe-' . hash('md5', $postMedia->embedHtml), + 'src' => $postMedia->embedHtml, + 'height' => $postMedia->embedHeight + 20, + 'width' => $postMedia->embedWidth && $postMedia->embedWidth <= $this->config->get('system', 'max_width') ? $postMedia->embedWidth : '100%', + ]); } public function getAudioAttachment(PostMediaEntity $postMedia): string diff --git a/src/Content/Text/BBCode.php b/src/Content/Text/BBCode.php index df5d930dda..ef4cf32e56 100644 --- a/src/Content/Text/BBCode.php +++ b/src/Content/Text/BBCode.php @@ -443,10 +443,19 @@ class BBCode $return = sprintf('
', $data['type']); } - if ($embed && $data['player_url'] != '' && $data['player_height'] != 0) { + if ($embed && (($data['player_url'] != '' && $data['player_height'] != 0) || $data['embed_html'] != '')) { $media = DI::postMediaFactory()->createFromAttachment($data, $uriid); - $return .= DI::postMediaRepository()->getPlayerIframe($media); + if ($data['player_url'] != '' && $data['player_height'] != 0) { + $return .= DI::postMediaRepository()->getPlayerIframe($media); + } else { + $return .= DI::postMediaRepository()->getEmbedIframe($media); + } $preview_mode = self::PREVIEW_NO_IMAGE; + unset($data['title']); + unset($data['url']); + unset($data['description']); + unset($data['provider_url']); + unset($data['provider_name']); } if ($preview_mode == self::PREVIEW_NO_IMAGE) { diff --git a/src/Model/Item.php b/src/Model/Item.php index d4a4aae49a..72334e4183 100644 --- a/src/Model/Item.php +++ b/src/Model/Item.php @@ -3405,6 +3405,10 @@ class Item 'player_url' => (string)$attachment->playerUrl, 'player_width' => $attachment->playerWidth, 'player_height' => $attachment->playerHeight, + 'embed_type' => $attachment->embedType, + 'embed_html' => $attachment->embedHtml, + 'embed_width' => $attachment->embedWidth, + 'embed_height' => $attachment->embedHeight, ]; if ($preview && $attachment->preview) { diff --git a/src/Model/Post/Media.php b/src/Model/Post/Media.php index 89b1540ae9..a674729adc 100644 --- a/src/Model/Post/Media.php +++ b/src/Model/Post/Media.php @@ -473,6 +473,10 @@ class Media $media['player-url'] = $data['player']['embed'] ?? null; $media['player-height'] = $data['player']['height'] ?? null; $media['player-width'] = $data['player']['width'] ?? null; + $media['embed-type'] = $data['embed']['type'] ?? null; + $media['embed-html'] = $data['embed']['html'] ?? null; + $media['embed-height'] = $data['embed']['height'] ?? null; + $media['embed-width'] = $data['embed']['width'] ?? null; $media['language'] = $data['language'] ?? null; $media['published'] = $data['published'] ?? null; $media['modified'] = $data['modified'] ?? null; diff --git a/src/Protocol/ActivityPub/Receiver.php b/src/Protocol/ActivityPub/Receiver.php index d579d2725a..399d74cd63 100644 --- a/src/Protocol/ActivityPub/Receiver.php +++ b/src/Protocol/ActivityPub/Receiver.php @@ -1926,14 +1926,17 @@ class Receiver $attachment['player-height'] = $player['height'] ?? null; $attachment['player-width'] = $player['width'] ?? null; - foreach ($attachments as $media) { - if (isset($media['height']) && isset($media['width'])) { - if ($media['height'] > $attachment['player-height'] || $media['width'] > $attachment['player-width']) { - $attachment['player-height'] = $media['height']; - $attachment['player-width'] = $media['width']; + if (is_null($attachment['player-height']) && is_null($attachment['player-width'])) { + foreach ($attachments as $media) { + if (isset($media['height']) && isset($media['width'])) { + if ($media['height'] > $attachment['player-height'] || $media['width'] > $attachment['player-width']) { + $attachment['player-height'] = $media['height']; + $attachment['player-width'] = $media['width']; + } } } } + if (!$height && !$width) { $attachment['height'] = $attachment['player-height']; $attachment['width'] = $attachment['player-width']; diff --git a/src/Util/ParseUrl.php b/src/Util/ParseUrl.php index a63cdbc20e..c748c050e6 100644 --- a/src/Util/ParseUrl.php +++ b/src/Util/ParseUrl.php @@ -494,8 +494,6 @@ class ParseUrl } } - $siteinfo = self::getOembedInfo($xpath, $siteinfo); - $list = $xpath->query("//script[@type='application/ld+json']"); foreach ($list as $node) { if (!empty($node->nodeValue)) { @@ -506,6 +504,8 @@ class ParseUrl } } + $siteinfo = self::getOembedInfo($xpath, $siteinfo); + if (!empty($siteinfo['player']['stream'])) { // Only add player data to media arrays if there is no duplicate $content_urls = array_merge(array_column($siteinfo['audio'] ?? [], 'content'), array_column($siteinfo['video'] ?? [], 'content')); @@ -1299,50 +1299,58 @@ class ParseUrl } /** - * Fetch additional information via oEmbed + * Fetch oEmbed data * * @param DOMXPath $xpath - * @param array $siteinfo + * @param string $url * - * @return array siteinfo + * @return array oEmbed data */ - private static function getOembedInfo(DOMXPath $xpath, array $siteinfo): array + private static function getOembedData(DOMXPath $xpath, string $url): array { $oembed = ''; - foreach ($xpath->query("//link[@type='application/json+oembed']") as $link) { - /** @var DOMElement $link */ - $href = $link->getAttributeNode('href')->nodeValue; - $oembed = $href; - DI::logger()->debug('Found oEmbed JSON', ['url' => $href]); + $data = []; + + if (in_array(parse_url(Strings::normaliseLink($url), PHP_URL_HOST), ['twitter.com', 'x.com'])) { + $oembed = 'https://publish.twitter.com/oembed?url=' . urlencode($url) . '&dnt=true'; + + $systemLanguage = DI::config()->get('system', 'language'); + if ($systemLanguage) { + $oembed .= '&lang=' . $systemLanguage; + } + DI::logger()->debug('Using Twitter oEmbed', ['url' => $url, 'oembed' => $oembed]); } - if (empty($oembed)) { - $embera = new Embera(); - $urldata = $embera->getUrlData([$siteinfo['url']]); - if (empty($urldata)) { - return $siteinfo; - } - $data = current($urldata); - DI::logger()->debug('Found oEmbed JSON from Embera', ['url' => $siteinfo['url']]); - } else { - $result = DI::httpClient()->get($oembed, HttpClientAccept::DEFAULT, [HttpClientOptions::REQUEST => HttpClientRequest::SITEINFO]); - if (!$result->isSuccess()) { - return $siteinfo; - } - $json_string = $result->getBodyString(); - if (empty($json_string)) { - return $siteinfo; + if (!$oembed) { + foreach ($xpath->query("//link[@type='application/json+oembed']") as $link) { + /** @var DOMElement $link */ + $oembed = $link->getAttributeNode('href')->nodeValue; + DI::logger()->debug('Found oEmbed JSON from page', ['url' => $url, 'oembed' => $oembed]); } + } - $data = json_decode($json_string, true); + if ($oembed) { + $oembed .= '&maxwidth=' . DI::config()->get('system', 'max_width') . '&maxheight=' . DI::config()->get('system', 'max_height') . '&format=json'; + $result = DI::httpClient()->get($oembed, HttpClientAccept::DEFAULT, [HttpClientOptions::REQUEST => HttpClientRequest::SITEINFO]); + if ($result->isSuccess() && $result->getBodyString()) { + $data = json_decode($result->getBodyString(), true); + } } if (empty($data) || !is_array($data)) { - return $siteinfo; + $embera = new Embera(['maxwidth' => DI::config()->get('system', 'max_width'), 'maxheight' => DI::config()->get('system', 'max_height')]); + $urldata = $embera->getUrlData($url); + if (empty($urldata)) { + return []; + } + $data = current($urldata); + DI::logger()->debug('Found oEmbed JSON from Embera', ['url' => $url]); } + return $data; + } - DI::logger()->debug('Got oEmbed data', ['url' => $siteinfo['url'], 'type' => $data['type'], 'data' => $data]); - + private static function getSiteinfoFromoEmbed(array $siteinfo, array $data): array + { // Youtube provides only basic information to some IP ranges. // Dailymotion only provices "Dailymotion" as title in their meta tags, so oEmbed is better // @todo We have to decide if we always trust oEmbed more than the meta tags @@ -1382,6 +1390,8 @@ class ParseUrl if (isset($data[$key]) && (empty($siteinfo[$value]) || $overwrite)) { if ($value == 'published') { $siteinfo[$value] = DateTimeFormat::utc($data[$key]); + } elseif (is_string($value)) { + $siteinfo[$value] = trim(strip_tags(html_entity_decode($data[$key], ENT_COMPAT, 'UTF-8'))); } else { $siteinfo[$value] = $data[$key]; } @@ -1391,26 +1401,36 @@ class ParseUrl if (!empty($unknown_fields)) { DI::logger()->debug('Unknown oEmbed fields', ['url' => $siteinfo['url'], 'fields' => $unknown_fields]); } + return $siteinfo; + } - if (!empty($data['html']) && empty($siteinfo['player'])) { - $siteinfo = self::setPlayer($data, $siteinfo); + private static function getOembedInfo(DOMXPath $xpath, array $siteinfo): array + { + $data = self::getOembedData($xpath, $siteinfo['url']); + if (empty($data) || !is_array($data)) { + return $siteinfo; } + $siteinfo = self::getSiteinfoFromoEmbed($siteinfo, $data); + if ($data['type'] == 'video' & empty($siteinfo['player']) && ($data['provider_url'] ?? '') == 'https://www.tiktok.com' && isset($data['embed_product_id']) && isset($data['thumbnail_width']) && isset($data['thumbnail_height'])) { + $siteinfo['embed']['type'] = $data['type']; + $siteinfo['embed']['html'] = trim($data['html']); + $siteinfo['embed']['width'] = is_numeric($data['width'] ?? '') ? $data['width'] : $data['thumbnail_width']; + $siteinfo['embed']['height'] = is_numeric($data['height'] ?? '') ? $data['height'] : $data['thumbnail_height']; $siteinfo['player']['embed'] = 'https://www.tiktok.com/player/v1/' . $data['embed_product_id'] . '?description=1&rel=0'; - $siteinfo['player']['width'] = $data['thumbnail_width']; - $siteinfo['player']['height'] = $data['thumbnail_height']; + $siteinfo['player']['width'] = $siteinfo['embed']['width']; + $siteinfo['player']['height'] = $siteinfo['embed']['height']; + return $siteinfo; } - if (!empty($siteinfo['player'])) { - foreach (['width' => 'width', 'height' => 'height', 'fixedWidth' => 'width'] as $key => $value) { - if (empty($siteinfo['player'][$value]) && !empty($data[$key])) { - $siteinfo['player'][$value] = $data[$key]; - } - } + if (!isset($data['html'])) { + return $siteinfo; } - if ($data['type'] == 'rich' && isset($data['html']) && !isset($siteinfo['text'])) { + unset($siteinfo['player']); + + if ($data['type'] == 'rich' && !isset($siteinfo['text'])) { $bbcode = HTML::toBBCode($data['html'] ?? ''); $bbcode = preg_replace("(\[url\](.*?)\[\/url\])ism", "", $bbcode); @@ -1418,6 +1438,46 @@ class ParseUrl DI::logger()->debug('Text is fetched from oEmbed HTML', ['url' => $siteinfo['url'], 'text' => $siteinfo['text']]); } + $siteinfo = self::setPlayer($data, $siteinfo); + + if (!empty($siteinfo['player'])) { + $siteinfo['embed']['type'] = $data['type']; + $siteinfo['embed']['html'] = trim($data['html']); + $siteinfo['embed']['width'] = $siteinfo['player']['width']; + $siteinfo['embed']['height'] = $siteinfo['player']['height']; + return $siteinfo; + } + + if (($data['provider_url'] ?? '') == 'https://twitter.com') { + if (preg_match_all('#https?://t\.co/[a-zA-Z0-9]+#', $data['html'], $matches)) { + $links = array_unique($matches[0]); + foreach ($links as $link) { + $curlResult = DI::httpClient()->head($link); + $redirect = $curlResult->getRedirectUrl(); + if (preg_match('#/(video|broadcasts)/#', $redirect)) { + $siteinfo['embed']['type'] = $data['type']; + $siteinfo['embed']['html'] = trim(str_replace('