Skip to content

Automatic feed channel icons feature for xExtension-YouTube #337

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 18 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 24 additions & 17 deletions xExtension-YouTube/configure.phtml
Original file line number Diff line number Diff line change
Expand Up @@ -2,44 +2,51 @@
declare(strict_types=1);
/** @var YouTubeExtension $this */
?>
<form action="<?php echo _url('extension', 'configure', 'e', urlencode($this->getName())); ?>" method="post">
<input type="hidden" name="_csrf" value="<?php echo FreshRSS_Auth::csrfToken(); ?>" />
<form action="<?= _url('extension', 'configure', 'e', urlencode($this->getName())) ?>" method="post">
<input type="hidden" name="_csrf" value="<?= FreshRSS_Auth::csrfToken() ?>" />
<div class="form-group">

<label class="group-name" for="yt_height"><?php echo _t('ext.yt_videos.height'); ?></label>
<label class="group-name" for="yt_height"><?= _t('ext.yt_videos.height') ?></label>
<div class="group-controls">
<input type="number" id="yt_height" name="yt_height" value="<?php echo $this->getHeight(); ?>" min="50" data-leave-validation="1">
<input type="number" id="yt_height" name="yt_height" value="<?= $this->getHeight() ?>" min="50" data-leave-validation="<?= $this->getHeight() ?>" />
</div>

<label class="group-name" for="yt_width"><?php echo _t('ext.yt_videos.width'); ?></label>
<label class="group-name" for="yt_width"><?= _t('ext.yt_videos.width') ?></label>
<div class="group-controls">
<input type="number" id="yt_width" name="yt_width" value="<?php echo $this->getWidth(); ?>" min="100" data-leave-validation="1">
<input type="number" id="yt_width" name="yt_width" value="<?= $this->getWidth() ?>" min="100" data-leave-validation="<?= $this->getWidth() ?>" />
</div>

<div class="group-controls">
<label class="checkbox" for="yt_show_content">
<input type="checkbox" id="yt_show_content" name="yt_show_content" value="1" <?php echo $this->isShowContent() ? 'checked' : ''; ?>>
<?php echo _t('ext.yt_videos.show_content'); ?>
<input type="checkbox" id="yt_show_content" name="yt_show_content" data-leave-validation="<?= intval($this->isShowContent()) ?>" <?= $this->isShowContent() ? 'checked="checked"' : '' ?> />
<?= _t('ext.yt_videos.show_content') ?>
</label>
</div>

<div class="group-controls">
<label class="checkbox" for="yt_download_channel_icons">
<input type="checkbox" id="yt_download_channel_icons" name="yt_download_channel_icons" data-leave-validation="<?= intval($this->isDownloadIcons()) ?>" <?= $this->isDownloadIcons() ? 'checked="checked"' : '' ?> />
<?= _t('ext.yt_videos.download_channel_icons') ?>
</label>
</div>

<div class="group-controls">
<label class="checkbox" for="yt_nocookie">
<input type="checkbox" id="yt_nocookie" name="yt_nocookie" value="1" <?php echo $this->isUseNoCookieDomain() ? 'checked' : ''; ?>>
<?php echo _t('ext.yt_videos.use_nocookie'); ?>
<input type="checkbox" id="yt_nocookie" name="yt_nocookie" data-leave-validation="<?= intval($this->isUseNoCookieDomain()) ?>" <?= $this->isUseNoCookieDomain() ? 'checked="checked"' : '' ?> />
<?= _t('ext.yt_videos.use_nocookie') ?>
</label>
</div>

<div class="group-controls">
<button id="yt_action_btn" name="yt_action_btn" value="iconFetchFinish" type="submit" class="btn" disabled="disabled" title="<?= _t('gen.js.should_be_activated') ?>"><?= _t('ext.yt_videos.fetch_channel_icons') ?></button>
<button id="yt_action_btn" name="yt_action_btn" value="resetIcons" type="submit" class="btn"><?= _t('ext.yt_videos.reset_channel_icons') ?></button>
</div>
</div>

<div class="form-group form-actions">
<div class="group-controls">
<button type="submit" class="btn btn-important"><?php echo _t('gen.action.submit'); ?></button>
<button type="reset" class="btn"><?php echo _t('gen.action.cancel'); ?></button>
<button type="submit" class="btn btn-important"><?= _t('gen.action.submit') ?></button>
<button type="reset" class="btn"><?= _t('gen.action.cancel') ?></button>
</div>
</div>
</form>

<p>
<?php echo _t('ext.yt_videos.updates'); ?>
<a href="https://github.com/kevinpapst/freshrss-youtube" target="_blank">GitHub</a>.
</p>
268 changes: 265 additions & 3 deletions xExtension-YouTube/extension.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@
/**
* Class YouTubeExtension
*
* Latest version can be found at https://github.com/kevinpapst/freshrss-youtube
*
* @author Kevin Papst
*/
final class YouTubeExtension extends Minz_Extension
Expand All @@ -21,6 +19,10 @@
* Whether we display the original feed content
*/
private bool $showContent = false;
/**
* Wheter channel icons should be automatically downloaded and set for feeds
*/
private bool $downloadIcons = false;
/**
* Switch to enable the Youtube No-Cookie domain
*/
Expand All @@ -34,9 +36,214 @@
{
$this->registerHook('entry_before_display', [$this, 'embedYouTubeVideo']);
$this->registerHook('check_url_before_add', [self::class, 'convertYoutubeFeedUrl']);
$this->registerHook('custom_favicon_hash', [$this, 'iconHashParams']);
$this->registerHook('custom_favicon_btn_url', [$this, 'iconBtnUrl']);
$this->registerHook('feed_before_insert', [$this, 'feedBeforeInsert']);
if (Minz_Request::controllerName() === 'extension') {
$this->registerHook('js_vars', [self::class, 'jsVars']);
Minz_View::appendScript($this->getFileUrl('fetchIcons.js'));
}
$this->registerTranslates();
}

/**
* @param array<string,mixed> $vars
* @return array<string,mixed>
*/
public static function jsVars(array $vars): array {
$vars['yt_i18n'] = [
'fetching_icons' => _t('ext.yt_videos.fetching_icons'),
];
return $vars;
}

public function isYtFeed(string $website): bool {
return str_starts_with($website, 'https://www.youtube.com/');
}

public function iconBtnUrl(FreshRSS_Feed $feed): ?string {
if (!$this->isYtFeed($feed->website()) || $feed->attributeString('customFaviconExt') === $this->getName()) {
return null;
}
return _url('extension', 'configure', 'e', urlencode($this->getName()));
}

public function iconHashParams(FreshRSS_Feed $feed): ?string {
if ($feed->customFaviconExt() !== $this->getName()) {
return null;
}
return 'yt' . $feed->website() . $feed->proxyParam();
}

/**
* @throws Minz_PDOConnectionException
* @throws Minz_ConfigurationNamespaceException
*/
public function ajaxGetYtFeeds(): void {
$feedDAO = FreshRSS_Factory::createFeedDao();
$ids = $feedDAO->listFeedsIds();

$feeds = [];

foreach ($ids as $feedId) {
$feed = $feedDAO->searchById($feedId);
if ($feed === null) {
continue;
}
if ($this->isYtFeed($feed->website())) {
$feeds[] = [
'id' => $feed->id(),
'title' => $feed->name(true),
];
}
}

header('Content-Type: application/json; charset=UTF-8');
exit(json_encode($feeds));
}

/**
* @throws Minz_PDOConnectionException
* @throws Minz_ConfigurationNamespaceException
*/
public function ajaxFetchIcon(): void {
$feedDAO = FreshRSS_Factory::createFeedDao();

$feed = $feedDAO->searchById(Minz_Request::paramInt('id'));
if ($feed === null) {
Minz_Error::error(404);
return;
}
$this->setIconForFeed($feed, setValues: true);

Check failure on line 117 in xExtension-YouTube/extension.php

View workflow job for this annotation

GitHub Actions / tests

Method YouTubeExtension::ajaxFetchIcon() throws checked exception Minz_PermissionDeniedException but it's missing from the PHPDoc @throws tag.

exit('OK');
}

/**
* @throws Minz_PDOConnectionException
* @throws Minz_ConfigurationNamespaceException
* @throws Minz_PermissionDeniedException
*/
public function resetAllIcons(): void {
$feedDAO = FreshRSS_Factory::createFeedDao();
$ids = $feedDAO->listFeedsIds();

foreach ($ids as $feedId) {
$feed = $feedDAO->searchById($feedId);
if ($feed === null) {
continue;
}
if ($feed->customFaviconExt() === $this->getName()) {
$v = [];
try {
$feed->resetCustomFavicon(values: $v);
} catch (FreshRSS_Feed_Exception $_) {
$this->warnLog('failed to reset favicon for feed "' . $feed->name(true) . '": feed error');
}
}
}
}

/**
* @throws Minz_PermissionDeniedException
*/
public function warnLog(string $s): void {
Minz_Log::warning('[' . $this->getName() . '] ' . $s);
}
/**
* @throws Minz_PermissionDeniedException
*/
public function debugLog(string $s): void {
Minz_Log::debug('[' . $this->getName() . '] ' . $s);
}

/**
* @throws FreshRSS_Context_Exception
*/
public function feedBeforeInsert(FreshRSS_Feed $feed): FreshRSS_Feed {
$this->loadConfigValues();

if ($this->downloadIcons) {
return $this->setIconForFeed($feed);

Check failure on line 167 in xExtension-YouTube/extension.php

View workflow job for this annotation

GitHub Actions / tests

Method YouTubeExtension::feedBeforeInsert() throws checked exception Minz_PermissionDeniedException but it's missing from the PHPDoc @throws tag.
}

return $feed;
}

/**
* @throws Minz_PermissionDeniedException
*/
public function setIconForFeed(FreshRSS_Feed $feed, bool $setValues = false): FreshRSS_Feed {
if (!$this->isYtFeed($feed->website())) {
return $feed;
}

// Return early if the icon had already been downloaded before
$v = $setValues ? [] : null;
$oldAttributes = $feed->attributes();
try {
$path = $feed->setCustomFavicon(extName: $this->getName(), disallowDelete: true, values: $v);

Check failure on line 185 in xExtension-YouTube/extension.php

View workflow job for this annotation

GitHub Actions / tests

Method YouTubeExtension::setIconForFeed() throws checked exception FreshRSS_UnsupportedImageFormat_Exception but it's missing from the PHPDoc @throws tag.
if ($path === null) {
$feed->_attributes($oldAttributes);
return $feed;
} elseif (file_exists($path)) {
$this->debugLog('icon had already been downloaded before for feed "' . $feed->name(true) . '" - returning early!');
return $feed;
}
} catch (FreshRSS_Feed_Exception $_) {
$this->warnLog('failed to set custom favicon for feed "' . $feed->name(true) . '": feed error');
$feed->_attributes($oldAttributes);
return $feed;
}

$feed->_attributes($oldAttributes);
$this->debugLog('downloading icon for feed "' . $feed->name(true) . '"');

$url = $feed->website();
/** @var array<int, bool|int|string> */
$curlOptions = $feed->attributeArray('curl_params') ?? [];
$html = downloadHttp($url, $curlOptions);

$dom = new DOMDocument();

if ($html == '' || !@$dom->loadHTML($html, LIBXML_NONET | LIBXML_NOERROR | LIBXML_NOWARNING)) {
$this->warnLog('fail while downloading icon for feed "' . $feed->name(true) . '": failed to load HTML');
return $feed;
}

$xpath = new DOMXPath($dom);
$iconElem = $xpath->query('//meta[@name="twitter:image"]');

if ($iconElem === false) {
$this->warnLog('fail while downloading icon for feed "' . $feed->name(true) . '": icon URL couldn\'t be found');
return $feed;
}

if (!($iconElem->item(0) instanceof DOMElement)) {
$this->warnLog('fail while downloading icon for feed "' . $feed->name(true) . '": icon URL couldn\'t be found');
return $feed;
}

$iconUrl = $iconElem->item(0)->getAttribute('content');
$contents = downloadHttp($iconUrl, $curlOptions);
if ($contents == '') {
$this->warnLog('fail while downloading icon for feed "' . $feed->name(true) . '": empty contents');
return $feed;
}

try {
$feed->setCustomFavicon($contents, extName: $this->getName(), disallowDelete: true, values: $v, overrideCustomIcon: true);
} catch (FreshRSS_UnsupportedImageFormat_Exception $_) {
$this->warnLog('failed to set custom favicon for feed "' . $feed->name(true) . '": unsupported image format');
return $feed;
} catch (FreshRSS_Feed_Exception $_) {
$this->warnLog('failed to set custom favicon for feed "' . $feed->name(true) . '": feed error');
return $feed;
}

return $feed;
}

public static function convertYoutubeFeedUrl(string $url): string
{
$matches = [];
Expand Down Expand Up @@ -78,6 +285,11 @@
$this->showContent = $showContent;
}

$downloadIcons = FreshRSS_Context::userConf()->attributeBool('yt_download_channel_icons');
if ($downloadIcons !== null) {
$this->downloadIcons = $downloadIcons;
}

$noCookie = FreshRSS_Context::userConf()->attributeBool('yt_nocookie');
if ($noCookie !== null) {
$this->useNoCookie = $noCookie;
Expand Down Expand Up @@ -111,6 +323,15 @@
return $this->showContent;
}

/**
* Returns whether the automatic icon download option is enabled.
* You have to call loadConfigValues() before this one, otherwise you get default values.
*/
public function isDownloadIcons(): bool
{
return $this->downloadIcons;
}

/**
* Returns if this extension should use youtube-nocookie.com instead of youtube.com.
* You have to call loadConfigValues() before this one, otherwise you get default values.
Expand Down Expand Up @@ -245,17 +466,58 @@
* - We save configuration in case of a post.
* - We (re)load configuration in all case, so they are in-sync after a save and before a page load.
* @throws FreshRSS_Context_Exception
* @throws Minz_PDOConnectionException
* @throws Minz_ConfigurationNamespaceException
*/
#[\Override]
public function handleConfigureAction(): void
{
$this->registerTranslates();

if (Minz_Request::isPost()) {
// for handling requests from `custom_favicon_btn_url` hook
$extAction = Minz_Request::paramStringNull('extAction');
if ($extAction !== null) {
$feedDAO = FreshRSS_Factory::createFeedDao();
$feed = $feedDAO->searchById(Minz_Request::paramInt('id'));
if ($feed === null || !$this->isYtFeed($feed->website())) {
Minz_Error::error(404);
return;
}

$this->setIconForFeed($feed, setValues: $extAction === 'update_icon');

Check failure on line 488 in xExtension-YouTube/extension.php

View workflow job for this annotation

GitHub Actions / tests

Method YouTubeExtension::handleConfigureAction() throws checked exception Minz_PermissionDeniedException but it's missing from the PHPDoc @throws tag.
if ($extAction === 'query_icon_info') {
header('Content-Type: application/json; charset=UTF-8');
exit(json_encode([
'extName' => $this->getName(),
'iconUrl' => $feed->favicon(),
]));
}

exit('OK');
}

// for handling configure page
switch (Minz_Request::paramString('yt_action_btn')) {
case 'ajaxGetYtFeeds':
$this->ajaxGetYtFeeds();
return;
case 'ajaxFetchIcon':
$this->ajaxFetchIcon();
return;
// non-ajax actions
case 'iconFetchFinish': // called after final ajaxFetchIcon call
Minz_Request::good(_t('ext.yt_videos.finished_fetching_icons'), ['c' => 'extension']);
break;
case 'resetIcons':
$this->resetAllIcons();

Check failure on line 513 in xExtension-YouTube/extension.php

View workflow job for this annotation

GitHub Actions / tests

Method YouTubeExtension::handleConfigureAction() throws checked exception Minz_PermissionDeniedException but it's missing from the PHPDoc @throws tag.
break;
}
FreshRSS_Context::userConf()->_attribute('yt_player_height', Minz_Request::paramInt('yt_height'));
FreshRSS_Context::userConf()->_attribute('yt_player_width', Minz_Request::paramInt('yt_width'));
FreshRSS_Context::userConf()->_attribute('yt_show_content', Minz_Request::paramBoolean('yt_show_content'));
FreshRSS_Context::userConf()->_attribute('yt_nocookie', Minz_Request::paramInt('yt_nocookie'));
FreshRSS_Context::userConf()->_attribute('yt_download_channel_icons', Minz_Request::paramBoolean('yt_download_channel_icons'));
FreshRSS_Context::userConf()->_attribute('yt_nocookie', Minz_Request::paramBoolean('yt_nocookie'));
FreshRSS_Context::userConf()->save();
}

Expand Down
Loading
Loading