From 65f62a46631909eca8880d5006ae32d8d8fdadd4 Mon Sep 17 00:00:00 2001 From: Alexander Dreweke Date: Wed, 24 Jun 2026 08:24:18 +0200 Subject: [PATCH] fix: YouTubeFeedExpanderBridge getIcon() null pointer and performance getIcon() had two issues: 1. Null pointer exception: The find() call for [itemprop="thumbnailUrl"] can return null when the element is not present on the page, causing a fatal error when accessing ->href on the result. 2. Unnecessary HTML fetching: Every call to getIcon() fetched and parsed the full YouTube channel HTML page just to extract the icon URL. On instances with a higher number of YouTube feeds, this causes throttling or outright blocking by YouTube, since each feed triggers a full page load solely to resolve the channel icon. Fix the null pointer by checking the find() result before accessing its property, falling back to the parent FeedExpander::getIcon() which returns the generic YouTube favicon. Cache the resolved icon URL via loadCacheValue/saveCacheValue (24h TTL) so subsequent requests return the cached string directly without fetching the channel page, significantly reducing the number of requests made to YouTube. Add dedicated test coverage for parseItem(), hideshorts filtering, embed/nocookie URL handling, and the getIcon() caching and fallback behavior. --- bridges/YouTubeFeedExpanderBridge.php | 13 +++- tests/YouTubeFeedExpanderBridgeTest.php | 97 +++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 tests/YouTubeFeedExpanderBridgeTest.php diff --git a/bridges/YouTubeFeedExpanderBridge.php b/bridges/YouTubeFeedExpanderBridge.php index 2faac6379fa..5a90b1a335e 100644 --- a/bridges/YouTubeFeedExpanderBridge.php +++ b/bridges/YouTubeFeedExpanderBridge.php @@ -40,10 +40,21 @@ class YouTubeFeedExpanderBridge extends FeedExpander public function getIcon() { + $cacheKey = 'icon_' . $this->getInput('channel'); + $icon = $this->loadCacheValue($cacheKey); + if ($icon) { + return $icon; + } + if ($this->getInput('channel') != null) { $html = getSimpleHTMLDOMCached($this->getURI()); - return $html->find('[itemprop="thumbnailUrl"]', 0)->href; + $thumbnail = $html->find('[itemprop="thumbnailUrl"]', 0); + if ($thumbnail) { + $this->saveCacheValue($cacheKey, $thumbnail->href); + return $thumbnail->href; + } } + return parent::getIcon(); } diff --git a/tests/YouTubeFeedExpanderBridgeTest.php b/tests/YouTubeFeedExpanderBridgeTest.php new file mode 100644 index 00000000000..3952d4d6b41 --- /dev/null +++ b/tests/YouTubeFeedExpanderBridgeTest.php @@ -0,0 +1,97 @@ +setInput(['channel' => 'UCtest']); + + $item = [ + 'uri' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + 'id' => 'yt:video:dQw4w9WgXcQ', + 'yt' => ['videoId' => 'dQw4w9WgXcQ'], + 'media' => ['group' => ['description' => "line1\nline2"]], + ]; + + $method = new \ReflectionMethod($bridge, 'parseItem'); + $method->setAccessible(true); + $result = $method->invoke($bridge, $item); + + $this->assertSame(['https://img.youtube.com/vi/dQw4w9WgXcQ/maxresdefault.jpg'], $result['enclosures']); + $this->assertSame('https://www.youtube.com/watch?v=dQw4w9WgXcQ#comments', $result['comments']); + $this->assertSame('yt:video:dQw4w9WgXcQ', $result['uid']); + $this->assertStringContainsString('line1
line2', $result['content']); + $this->assertArrayNotHasKey('media', $result); + } + + public function testHideShorts() + { + $bridge = new \YouTubeFeedExpanderBridge(new \ArrayCache(), new \NullLogger()); + $bridge->setInput(['channel' => 'UCtest', 'hideshorts' => 'on']); + + $method = new \ReflectionMethod($bridge, 'parseItem'); + $method->setAccessible(true); + + $shortsItem = [ + 'uri' => 'https://www.youtube.com/shorts/abc123', + 'id' => 'yt:video:abc123', + 'yt' => ['videoId' => 'abc123'], + 'media' => ['group' => ['description' => 'A short']], + ]; + $this->assertNull($method->invoke($bridge, $shortsItem)); + + $regularItem = [ + 'uri' => 'https://www.youtube.com/watch?v=abc123', + 'id' => 'yt:video:abc123', + 'yt' => ['videoId' => 'abc123'], + 'media' => ['group' => ['description' => 'A video']], + ]; + $this->assertNotNull($method->invoke($bridge, $regularItem)); + } + + public function testEmbedUrl() + { + $method = new \ReflectionMethod(\YouTubeFeedExpanderBridge::class, 'parseItem'); + $method->setAccessible(true); + + $item = [ + 'uri' => 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', + 'id' => 'yt:video:dQw4w9WgXcQ', + 'yt' => ['videoId' => 'dQw4w9WgXcQ'], + 'media' => ['group' => ['description' => 'A video']], + ]; + + $bridge = new \YouTubeFeedExpanderBridge(new \ArrayCache(), new \NullLogger()); + $bridge->setInput(['channel' => 'UCtest', 'embedurl' => 'on']); + $result = $method->invoke($bridge, $item); + $this->assertSame('https://www.youtube.com/embed/dQw4w9WgXcQ', $result['uri']); + + $bridge = new \YouTubeFeedExpanderBridge(new \ArrayCache(), new \NullLogger()); + $bridge->setInput(['channel' => 'UCtest', 'embedurl' => 'on', 'nocookie' => 'on']); + $result = $method->invoke($bridge, $item); + $this->assertSame('https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ', $result['uri']); + } + + public function testGetIconCache() + { + $cache = new \ArrayCache(); + $bridge = new \YouTubeFeedExpanderBridge($cache, new \NullLogger()); + $bridge->setInput(['channel' => 'UCtest']); + + $cache->set('YouTubeFeedExpanderBridge_icon_UCtest', 'https://example.com/icon.jpg'); + $this->assertSame('https://example.com/icon.jpg', $bridge->getIcon()); + } + + public function testGetIconFallback() + { + $bridge = new \YouTubeFeedExpanderBridge(new \ArrayCache(), new \NullLogger()); + $this->assertSame('https://www.youtube.com/favicon.ico', $bridge->getIcon()); + } +}