Skip to content
Snippets Groups Projects
YoutubeService.php 13 KiB
Newer Older
Stefan Galinski's avatar
Stefan Galinski committed

/***************************************************************
 *  Copyright notice
 *
 *  (c) sgalinski Internet Services (https://www.sgalinski.de)
 *
 *  All rights reserved
 *
 *  This script is part of the TYPO3 project. The TYPO3 project is
 *  free software; you can redistribute it and/or modify
 *  it under the terms of the GNU General Public License as published by
 *  the Free Software Foundation; either version 3 of the License, or
 *  (at your option) any later version.
 *
 *  The GNU General Public License can be found at
 *  http://www.gnu.org/copyleft/gpl.html.
 *
 *  This script is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU General Public License for more details.
 *
 *  This copyright notice MUST APPEAR in all copies of the script!
 ***************************************************************/

Georgi's avatar
Georgi committed
namespace SGalinski\SgYoutube\Service;

use Exception;
use GuzzleHttp\Exception\ClientException;
Georgi's avatar
Georgi committed
use Psr\EventDispatcher\EventDispatcherInterface;
use SGalinski\SgYoutube\Event\BeforeYoutubeRESTEvent;
use SGalinski\SgYoutube\Filter\FilterParameterBag;
use TYPO3\CMS\Core\Cache\Frontend\FrontendInterface;
use TYPO3\CMS\Core\Context\Context;
use TYPO3\CMS\Core\Context\Exception\AspectNotFoundException;
use TYPO3\CMS\Core\Context\LanguageAspect;
use TYPO3\CMS\Core\Http\RequestFactory;
use TYPO3\CMS\Core\Http\ServerRequest;
use TYPO3\CMS\Core\Site\Entity\Site;
use TYPO3\CMS\Core\Utility\GeneralUtility;

Stefan Galinski's avatar
Stefan Galinski committed
class YoutubeService {
	public const API_URL = 'https://www.googleapis.com/youtube/v3/';
	public const API_CHANNEL = 'search';
	public const API_PLAYLIST = 'playlistItems';
	public const API_VIDEO = 'videos';
	public const API_PART = 'snippet';
	public const API_PART_LOCALIZATIONS = 'localizations';
	public const API_ORDER_BY = 'date';
	public const CACHE_LIFETIME_IN_SECONDS = 86400;

	/**
	 * @var FrontendInterface
	 */
	protected $cache;

	/**
	 * @param FrontendInterface $cache
	 */
	public function __construct(FrontendInterface $cache) {
		$this->cache = $cache;
	}

	/**
	 * Maps the json array from the YouTube call to return some unified value. The output from YouTube is pretty
	 * unsteady. Also we calculate the correct thumbnail sized and so on.
	 *
	 * @param array $jsonArray
	 * @param string $youtubeId
	 * @param string $aspectRatio 16:9 (default) or 4:3 (ONLY used if byAspectRation is set as thumbnail type)
	 * @param string $thumbnailType maxres, standard, high, medium, default, byAspectRatio (default)
	 * @param string $apiKey
	 * @param bool $isShorts indicates whether the video is a "YouTube Shorts" video
Stefan Galinski's avatar
Stefan Galinski committed
	 * @return array
Georgi's avatar
Georgi committed
	 * @throws Exception
Stefan Galinski's avatar
Stefan Galinski committed
	 */
	public function mapArray(
		$jsonArray = [],
		$youtubeId = '',
		$aspectRatio = '16:9',
		$thumbnailType = 'byAspectRatio',
		$apiKey = '',
		$isShorts = FALSE
Stefan Galinski's avatar
Stefan Galinski committed
	): array {
		if (count($jsonArray) <= 0) {
			return $jsonArray;
		}

		// Normalize the data to video details.
		if (str_starts_with($youtubeId, 'UC') || str_starts_with($youtubeId, 'PL')) {
Stefan Galinski's avatar
Stefan Galinski committed
			$result = $this->getDetailedVideoInformationForJsonArray($jsonArray, $apiKey, self::API_PART);
			if (count($result) <= 0 || !isset($result['items'])) {
				return $jsonArray;
			}

			$jsonArray = $result['items'];
		}

		if (!in_array($thumbnailType, ['maxres', 'standard', 'high', 'medium', 'default', 'byAspectRatio'])) {
			$thumbnailType = 'byAspectRatio';
		}

		if (!in_array($aspectRatio, ['16:9', '4:3'])) {
			$aspectRatio = '16:9';
		}

		$context = GeneralUtility::makeInstance(Context::class);
		try {
			/** @var LanguageAspect $languageAspect */
			$languageAspect = $context->getAspect('language');
			$currentLanguageUid = $languageAspect->getId();
		} catch (AspectNotFoundException $e) {
			// Can't be possible to land here, otherwise the whole frontend would be weird as hell..
			$currentLanguageUid = 0;
		}

		if ($currentLanguageUid > 0 && $youtubeId && $apiKey) {
			$jsonArray = $this->addLocalizationData($jsonArray, $apiKey, $currentLanguageUid);
		}

		$result = [];
		foreach ($jsonArray as $field) {
			$previewImage = [];
			$resolutionTypes = ['maxres', 'standard', 'high', 'medium', 'default'];
			if ($thumbnailType !== 'byAspectRatio') {
				array_unshift($resolutionTypes, $thumbnailType);
			}

			foreach ($resolutionTypes as $type) {
				if (isset($field['snippet']['thumbnails'][$type])) {
					$previewImage = $field['snippet']['thumbnails'][$type];
					if ($thumbnailType === 'byAspectRatio' && isset($previewImage['height'])) {
						$aspectRatioOfImage = $previewImage['width'] / $previewImage['height'];
						if ($aspectRatio === '16:9' && $aspectRatioOfImage > 1.7 && $aspectRatioOfImage < 1.9) {
							break;
						}

						if ($aspectRatio === '4:3' && $aspectRatioOfImage > 1.2 && $aspectRatioOfImage < 1.4) {
							break;
						}
					} else {
						break;
					}
				}
			}

			$videoUrl = $isShorts
				? 'https://youtube.com/shorts/' . $field['id']
				: 'https://www.youtube.com/watch?v=' . $field['id'];

Stefan Galinski's avatar
Stefan Galinski committed
			// Don't cache the preview URL. YouTube has a CDN and delivers much faster.
			$result[] = [
				'title' => $field['snippet']['title'],
				'description' => strip_tags($field['snippet']['description']),
				'thumbnail' => $previewImage['url'],
				'url' => $videoUrl,
Stefan Galinski's avatar
Stefan Galinski committed
				'publishedAt' => $field['snippet']['publishedAt'],
			];
		}

		return $result;
	}

	/**
	 * Adds the localized title and description for each of the given entries in the jsonArray and returns it.
	 *
	 * @param array $jsonArray
	 * @param string $apiKey
	 * @param int $currentLanguageUid
	 *
	 * @return array
	 */
	protected function addLocalizationData(array $jsonArray, $apiKey, $currentLanguageUid): array {
		if (!$apiKey || !$currentLanguageUid || count($jsonArray) <= 0) {
			return $jsonArray;
		}

		$localizationData = $this->getDetailedVideoInformationForJsonArray(
			$jsonArray,
			$apiKey,
			self::API_PART_LOCALIZATIONS
		);
		if (!isset($localizationData['items']) || (is_countable($localizationData['items']) ? count(
			$localizationData['items']
		) : 0) <= 0) {
Stefan Galinski's avatar
Stefan Galinski committed
			return $jsonArray;
		}

		$site = $this->getSite();
		if ($site === NULL) {
			return $jsonArray;
		}

		$languages = $site->getLanguages();
		$currentSiteLanguage = $languages[$currentLanguageUid];
		if (!$currentSiteLanguage) {
			return $jsonArray;
		}

		$languageIsoCodes = [
			$currentSiteLanguage->getLocale()->getLanguageCode()
Stefan Galinski's avatar
Stefan Galinski committed
		];
		foreach ($currentSiteLanguage->getFallbackLanguageIds() as $languageId) {
			$siteLanguage = $languages[$languageId];
			if (!$siteLanguage) {
				continue;
			}

			$languageIsoCodes[] = $siteLanguage->getLocale()->getLanguageCode();
Stefan Galinski's avatar
Stefan Galinski committed
		}

		foreach ($localizationData['items'] as $index => $localizationEntry) {
			if (
				!isset($localizationEntry['localizations'])
				|| (is_countable($localizationEntry['localizations']) ? count($localizationEntry['localizations']) : 0) <= 0
			) {
Stefan Galinski's avatar
Stefan Galinski committed
				continue;
			}

			$title = '';
			$description = '';
			$localizations = $localizationEntry['localizations'];
			foreach ($languageIsoCodes as $languageIsoCode) {
				if ($title && $description) {
					break;
				}

				if (!$title && isset($localizations[$languageIsoCode]['title'])) {
					$title = $localizations[$languageIsoCode]['title'];
				}

				if (!$description && isset($localizations[$languageIsoCode]['description'])) {
					$description = $localizations[$languageIsoCode]['description'];
				}
			}

			if ($title) {
				$jsonArray[$index]['snippet']['title'] = $title;
			}

			if ($description) {
				$jsonArray[$index]['snippet']['description'] = $description;
			}
		}

		return $jsonArray;
	}

	/**
	 * Returns the detailed video information for the given json array and returns them as an array.
	 *
	 * @param array $jsonArray
	 * @param string $apiKey
	 * @param string $part
	 *
	 * @return array
	 * @throws Exception
Stefan Galinski's avatar
Stefan Galinski committed
	 */
	public function getDetailedVideoInformationForJsonArray(array $jsonArray, $apiKey, $part): array {
		if (!$apiKey || count($jsonArray) <= 0) {
			return $jsonArray;
		}

		$apiUrl = self::API_URL . self::API_VIDEO;
		$parameters = [];
		$parameters['part'] = $part;
		$parameters['key'] = $apiKey;
		$query = http_build_query($parameters);
		foreach ($jsonArray as $videoData) {
			$videoId = '';
			if (isset($videoData['snippet']['resourceId']['videoId'])) {
				$videoId = trim($videoData['snippet']['resourceId']['videoId']);
			}

			if (!$videoId && isset($videoData['id'])) {
				$videoId = $videoData['id']['videoId'] ?? $videoData['id'];
				// This is a check, because the $videoData['id'] can be a whole sub-channel-id.
				if (is_array($videoId)) {
					continue;
				}

				$videoId = trim($videoId);
			}

			if (!$videoId) {
				continue;
			}

			$query .= '&id=' . $videoId;
		}

Georgi's avatar
Georgi committed
		$result = $this->getJsonAsArray(new FilterParameterBag([
			'id' => '',
			'maxResults' => '10',
			'apiKey' => $apiKey,
			'url' => $apiUrl . '?' . $query
		]));
Stefan Galinski's avatar
Stefan Galinski committed
		if (!isset($result['items']) || (is_countable($result['items']) ? count($result['items']) : 0) <= 0) {
			return $jsonArray;
		}

		return $result;
	}

	/**
	 * Returns a JSON array with the video details (title, description, preview image, url)
	 *
	 * @param string $youtubeId
	 * @param string $maxResults
	 * @param string $apiKey
	 * @param string $url
Georgi's avatar
Georgi committed
	 * @param string $queryString
Stefan Galinski's avatar
Stefan Galinski committed
	 * @return array|mixed
Stefan Galinski's avatar
Stefan Galinski committed
	 * @throws Exception
Stefan Galinski's avatar
Stefan Galinski committed
	 */
Georgi's avatar
Georgi committed
	public function getJsonAsArray(
		FilterParameterBag $parameterBag
	) {
		$parameters = $parameterBag->all();

		// Dynamically build the API URL if not given
		$url = $parameterBag->get('url', $this->getApiUrl($parameters, $parameterBag->getFilterInstances()));
Stefan Galinski's avatar
Stefan Galinski committed

		$cacheKey = 'sg_youtube' . sha1($url);
Stefan Galinski's avatar
Stefan Galinski committed
		$disableYoutubeCache = (bool) GeneralUtility::_GP('disableYoutubeCache');
Stefan Galinski's avatar
Stefan Galinski committed
		if (!$disableYoutubeCache) {
			$cachedResult = $this->cache->get($cacheKey);
			if ($cachedResult) {
				return $cachedResult;
			}
		}

		$requestFactory = GeneralUtility::makeInstance(RequestFactory::class);
		try {
			$site = $this->getSite();
			if ($site === NULL) {
				throw new Exception('No site object found!');
			}
Stefan Galinski's avatar
Stefan Galinski committed
			$response = $requestFactory->request($url, 'GET', [
				'headers' => [
					'Referer' => $site->getBase()->getHost()
				]
			]);
Stefan Galinski's avatar
Stefan Galinski committed
			$jsonString = (string) $response->getBody();
Stefan Galinski's avatar
Stefan Galinski committed
		} catch (ClientException $exception) {
Stefan Galinski's avatar
Stefan Galinski committed
			$jsonString = (string) $exception->getResponse()->getBody();
Stefan Galinski's avatar
Stefan Galinski committed
		}

		$jsonArray = ($jsonString !== '' ? json_decode($jsonString, TRUE) : []);
		if ($jsonArray === NULL) {
			throw new InvalidArgumentException(
				'There is something wrong with loaded JSON or encoded data is deeper than the recursion limit.',
				403
			);
		}

		if (!$jsonArray) {
			throw new InvalidArgumentException('JSON could\'t be parsed.', 403);
		}

		// By API documentation provided 'error' key is sent if, probably,
Stefan Galinski's avatar
Stefan Galinski committed
		// URL cannot return JSON data or Permission is denied.
Stefan Galinski's avatar
Stefan Galinski committed
		if (isset($jsonArray['error'])) {
			throw new InvalidArgumentException('Message: ' . $jsonArray['error']['message'], 403);
		}

		if (!isset($jsonArray['items'])) {
			throw new InvalidArgumentException('No items array.', 403);
		}

		if ((is_countable($jsonArray['items']) ? count($jsonArray['items']) : 0) < 1) {
			throw new InvalidArgumentException('No items found.', 403);
		}

		if (!$disableYoutubeCache) {
			$this->cache->set($cacheKey, $jsonArray, [], self::CACHE_LIFETIME_IN_SECONDS);
		}

		return $jsonArray;
	}

	/**
	 * Returns the YouTube API URL
	 *
Georgi's avatar
Georgi committed
	 * @param array $params
	 * @param array $filters
Stefan Galinski's avatar
Stefan Galinski committed
	 * @return string
	 */
Georgi's avatar
Georgi committed
	public function getApiUrl(
		array $params, array $filters
	): string {
		$youtubeId = $params['id'] ?? '';
		$maxResults = $params['maxResults'] ?? '';
		$apiKey = $params['apiKey'] ?? $params['key'] ?? '';
		$queryString = $params['queryString'] ?? '';
Stefan Galinski's avatar
Stefan Galinski committed
		$apiUrl = self::API_URL;
		$parameters = [];

		if (str_starts_with($youtubeId, 'UC')) {
Stefan Galinski's avatar
Stefan Galinski committed
			$apiUrl .= self::API_CHANNEL;
			$parameters['channelId'] = $youtubeId;
		} elseif (str_starts_with($youtubeId, 'PL')) {
Stefan Galinski's avatar
Stefan Galinski committed
			$apiUrl .= self::API_PLAYLIST;
			$parameters['playlistId'] = $youtubeId;
		} else {
			$apiUrl .= self::API_VIDEO;
			$parameters['id'] = $this->removeIdParameters($youtubeId);
		}

Georgi's avatar
Georgi committed
		if ($queryString) {
			$parameters['q'] = $queryString;
		}

Stefan Galinski's avatar
Stefan Galinski committed
		$parameters['order'] = self::API_ORDER_BY;
		$parameters['part'] = self::API_PART;
		$parameters['key'] = $apiKey;
		$parameters['maxResults'] = $maxResults;
Georgi's avatar
Georgi committed

		foreach ($filters as $filter) {
			$filter->modifyRequest($parameters);
		}

Stefan Galinski's avatar
Stefan Galinski committed
		$query = http_build_query($parameters);

		return $apiUrl . '?' . $query;
	}

	/**
	 * Removes GET parameters following the ID
	 *
	 * @param string $youtubeId
	 * @return array|string
	 */
	protected function removeIdParameters($youtubeId = '') {
		if (strpos($youtubeId, '&')) {
			return explode('&', $youtubeId)[0];
		}

		return $youtubeId;
	}

	/**
	 * Get the current site of the request
	 *
	 * @return Site|null
	 */
	protected function getSite(): ?Site {
		/** @var ServerRequest $request */
		$request = $GLOBALS['TYPO3_REQUEST'];
		$attributes = $request->getAttributes();
		if (!isset($attributes['site'])) {
			return NULL;
		}

		return $attributes['site'];
	}