<?php declare(strict_types=1);
namespace P2Lab\ProductVideo\Subscriber;
use Monolog\Logger;
use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityWrittenEvent;
use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityDeletedEvent;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Aggregation\Metric\CountAggregation;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Shopware\Core\Content\Media\MediaEvents;
use Shopware\Core\Content\Product\ProductEvents;
use Doctrine\DBAL\Connection;
use Doctrine\DBAL\Query\QueryBuilder;
use P2Lab\ProductVideo\Helper\EmbedVideoHelper;
use Shopware\Core\Content\Media\File\FileFetcher;
use Shopware\Core\Content\Media\File\FileSaver;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Shopware\Core\Content\Product\ProductCollection;
use Shopware\Core\Content\Product\ProductEntity;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepositoryInterface;
use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
use Shopware\Core\System\SystemConfig\SystemConfigService;
use Symfony\Component\HttpKernel\KernelEvents;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use Shopware\Core\Framework\DataAbstractionLayer\Search\AggregationResult\Metric\CountResult;
use Psr\Log\LoggerInterface;
use Shopware\Core\Content\Product\Aggregate\ProductMedia\ProductMediaEntity;
use Shopware\Core\Framework\Uuid\Uuid;
class MediaSubscriber implements EventSubscriberInterface
{
private Connection $connection;
private EntityRepositoryInterface $productRepository;
/** @var EntityRepositoryInterface */
private $mediaRepository;
/** @var EntityRepositoryInterface */
private EntityRepositoryInterface $productMediaRepository;
/** @var FileFetcher */
private FileFetcher $fileFetcher;
/** @var FileSaver */
private FileSaver $fileSaver;
/** @var EventDispatcherInterface */
private EventDispatcherInterface $eventDispatcher;
/** @var SystemConfigService */
private SystemConfigService $systemConfigService;
/** @var LoggerInterface */
private $logger;
/** @var bool */
private bool $isBulkApiEnabled = false;
/** @var array */
private static $productPayloads = [];
/** @var bool */
private static bool $isActiveEventOnProductWritten = true;
private array $logStats = [
'detected' => [],
'found' => [],
'skipped' => [],
'added' => [],
'notRecognized' => 0,
'ambiguous' => 0,
];
/**
* @internal
*/
public function __construct(
Connection $connection,
EntityRepositoryInterface $productRepository,
$mediaRepository,
EntityRepositoryInterface $productMediaRepository,
FileFetcher $fileFetcher,
FileSaver $fileSaver,
EventDispatcherInterface $eventDispatcher,
SystemConfigService $systemConfigService,
LoggerInterface $logger
)
{
$this->connection = $connection;
$this->productRepository = $productRepository;
$this->mediaRepository = $mediaRepository;
$this->productMediaRepository = $productMediaRepository;
$this->fileFetcher = $fileFetcher;
$this->fileSaver = $fileSaver;
$this->eventDispatcher = $eventDispatcher;
$this->systemConfigService = $systemConfigService;
$this->logger = $logger;
$this->isBulkApiEnabled = (bool) $this->systemConfigService->get('P2LabProductVideo.config.bulkApiEnabled');
}
/**
* @return array
*/
public static function getSubscribedEvents(): array
{
return [
KernelEvents::REQUEST => 'onKernelRequest',
MediaEvents::MEDIA_DELETED_EVENT => 'onMediaDeleted',
ProductEvents::PRODUCT_WRITTEN_EVENT => 'onProductWritten',
];
}
/**
* @param array<mixed> $array
*/
private function isCollection(array $array): bool
{
return array_keys($array) === range(0, \count($array) - 1);
}
/**
* @param RequestEvent $event
*/
public function onKernelRequest(RequestEvent $event): void
{
if (! $this->isBulkApiEnabled) return;
/** @var Request $request */
$request = $event->getRequest();
if ($request->getPathInfo() !== '/api/_action/sync' || ! $request->isMethod('POST')) return;
/** @var array $operation */
foreach ($request->request->all() as $operation) {
if (
is_array($operation)
&&
isset($operation['action'])
&&
$operation['action'] === 'upsert'
&&
isset($operation['entity'])
&&
$operation['entity'] === 'product'
&&
isset($operation['payload'])
&&
is_array($operation['payload'])
) {
/** @var array $payload */
foreach ($operation['payload'] as $payload) {
if (
is_array($payload)
&&
isset($payload['p2labProductVideo'])
&&
is_array($payload['p2labProductVideo'])
) {
/** @var array $p2labProductVideo */
$p2labProductVideo = [];
if ($this->isCollection($payload['p2labProductVideo'])) {
$p2labProductVideo = $payload['p2labProductVideo'];
} else {
$p2labProductVideo[] = $payload['p2labProductVideo'];
}
$p2labProductVideo = array_filter($p2labProductVideo, function($video){
return is_array($video) && isset($video['url']);
});
if ($p2labProductVideo) {
/** @var array $productPayload */
$productPayload = [
'uuid' => Uuid::randomHex(),
'productData' => [],
'videoData' => [
'p2labProductVideo' => $p2labProductVideo,
],
];
if (isset($payload['id'])) {
$productPayload['videoData']['id'] = $payload['id'];
}
if (!empty($payload['productNumber'])) {
$productPayload['productData']['productNumber'] = md5($payload['productNumber']);
}
if (!empty($payload['name'])) {
$productPayload['productData']['name'] = md5($payload['name']);
}
$this->addLogStats('detected', $productPayload['uuid'], count($p2labProductVideo));
self::$productPayloads[] = $productPayload;
}
}
}
}
}
}
private function findProductPayloadByData(array $productData, bool $notUsed = true): array
{
/** @var array $foundKeys */
$foundKeys = [];
/** @var array $payload */
foreach (self::$productPayloads as $key => $payload) {
if (!isset($payload['productData'])) continue;
if ($notUsed && !empty($payload['used'])) continue;
/** @var int $theSame */
$theSame = 0;
foreach ($payload['productData'] as $dataKey => $dataValue) {
if (
isset($productData[$dataKey])
&&
$productData[$dataKey] === $dataValue
) {
$theSame++;
}
}
if ($theSame > 0 && $theSame === count($productData)) {
$foundKeys[] = $key;
}
}
return $foundKeys;
}
/**
* @param ProductEntity $product
* @return array
*/
private function findProductPayloadUuids($product): array {
/** @var array $productPayloadKeys */
if (null === ($productPayloadKeys = $this->findProductPayloadKeys($product, false))) {
return [];
}
/** @var array $uuids */
$uuids = [];
/** @var int $key */
foreach ($productPayloadKeys as $key) {
$uuids[] = self::$productPayloads[$key]['uuid'];
}
return $uuids;
}
/**
* @param ProductEntity $product
* @return int|null
*/
private function findProductPayloadKey($product, bool $notUsed = true): ?int {
/** @var array $foundKeys */
$foundKeys = $this->findProductPayloadKeys($product, $notUsed);
if (!$foundKeys) return null;
if (count($foundKeys) > 1) {
$this->logStats['ambiguous']++;
return null;
}
return $foundKeys[0];
}
/**
* @param ProductEntity $product
* @return array
*/
private function findProductPayloadKeys($product, bool $notUsed = true): array {
/** @var array $foundKeys */
$foundKeys = [];
/** @var array $payload */
foreach (self::$productPayloads as $key => $payload) {
if (
isset($payload['videoData']['id'])
&&
$payload['videoData']['id'] === $product->getId()
) {
if ($notUsed && !empty($payload['used'])) continue;
$foundKeys[] = $key;
}
}
if (!$foundKeys) {
$foundKeys = $this->findProductPayloadByData([
'productNumber' => md5($product->getProductNumber()),
], $notUsed);
if (!$foundKeys || count($foundKeys) > 1) {
$foundKeys = $this->findProductPayloadByData([
'productNumber' => md5($product->getProductNumber()),
'name' => md5($product->getName()),
], $notUsed);
}
}
return $foundKeys;
}
/**
* @param array $productIds
* @return array
*/
private function getProductsMediaUrls(array $productIds): array
{
if (! $productIds) return [];
/** @var QueryBuilder $query */
$query = new QueryBuilder($this->connection);
$query
->select('product_media.*')
->from('product_media')
->where('product_media.product_id IN (:productIds)')
->andWhere("JSON_EXTRACT(`product_media`.`custom_fields`, '$.\"p2labProductVideo\"') IS NOT NULL")
->setParameter('productIds', array_map(function($id){
return hex2bin($id);
}, $productIds), Connection::PARAM_STR_ARRAY);
/** @var array $productMediaUrls */
$productMediaUrls = [];
/** @var array $rows */
$rows = $query->execute()->fetchAllAssociative();
/** @var array $row */
foreach ($rows as $row) {
/** @var array $customFields */
$customFields = json_decode( $row['custom_fields'], true );
/** @var array $p2labProductVideo */
$p2labProductVideo = $customFields['p2labProductVideo'];
if (! empty($p2labProductVideo['url'])) {
$productMediaUrls[bin2hex($row['product_id'])][] = $p2labProductVideo['url'];
}
}
return $productMediaUrls;
}
/**
* @param EntityWrittenEvent $event
*/
public function onProductWritten(EntityWrittenEvent $event): void
{
if (! $this->isBulkApiEnabled) return;
/** @var array $productIds */
if (null == ($productIds = $event->getIds())) return;
if (! self::$isActiveEventOnProductWritten) return;
self::$isActiveEventOnProductWritten = false;
/** @var ProductCollection $products */
$products = $this->productRepository->search(new Criteria($productIds), $event->getContext())->getEntities();
/** @var array $productsMediaUrls */
$productsMediaUrls = $this->getProductsMediaUrls($productIds);
/** @var ProductEntity $product */
foreach ($products as $product) {
/** @var array $payloadKeys */
if (null != ($payloadKeys = $this->findProductPayloadKeys($product))) {
/** @var array $p2labProductVideos */
$p2labProductVideos = [];
/** @var array $uuids */
$uuids = [];
/** @var int $payloadKey */
foreach ($payloadKeys as $payloadKey) {
/** @var array $payload */
$payload = & self::$productPayloads[$payloadKey];
if (!empty($payload['used'])) continue;
/** @var string $uuid */
$uuid = $payload['uuid'];
$p2labProductVideos[$uuid] = $payload['videoData']['p2labProductVideo'];
$payload['used'] = true;
$uuids[] = $uuid;
$this->addLogStats('found', $uuid, count($payload['videoData']['p2labProductVideo']));
}
/** @var array $video */
foreach ($p2labProductVideos as $uuid => $videos) {
/** @var array $video */
foreach ($videos as $video) {
if (empty($video['autoThumbnail'])) {
if (empty($video['mediaId'])) {
$this->logger->error(sprintf(
"The product with ID '%s' has no 'p2labProductVideo.mediaId' or 'p2labProductVideo.autoThumbnail'.", $product->getId())
);
$this->addLogStats('skipped', $uuid);
continue;
}
}
if (empty($video['url'])) {
$this->logger->error(sprintf(
"The product with ID '%s' has no 'p2labProductVideo.url'.", $product->getId())
);
$this->addLogStats('skipped', $uuid);
continue;
}
if (!EmbedVideoHelper::recognizeEmbedVideoType($video['url'])) {
$this->logger->error(sprintf(
"The video source for the product with ID '%s' of '%s' is not recognized.", $product->getId(), $video['url'])
);
$this->addLogStats('skipped', $uuid);
continue;
}
if (isset($productsMediaUrls[$product->getId()])) {
/** @var string $url */
$url = EmbedVideoHelper::normalizeEmbedVideoUrl($video['url']);
if (in_array($url, $productsMediaUrls[$product->getId()])) {
$this->logger->debug(sprintf(
'The video source "%s" already exists in the product with ID "%s" and has been skipped.',
$url,
$product->getId()
));
$this->addLogStats('skipped', $uuid);
continue;
}
}
/** @var array $data */
$data = EmbedVideoHelper::upload(
$this->mediaRepository,
$this->fileFetcher,
$this->fileSaver,
$this->eventDispatcher,
[
'productId' => $product->getId(),
'mediaId' => empty($video['mediaId']) ? null : $video['mediaId'],
'customFields' => [
'p2labProductVideo' => [
'url' => $video['url'],
],
],
],
$event->getContext(),
null,
$this->logger
);
if (empty($data['mediaId'])) {
$this->addLogStats('skipped', $uuid);
continue;
}
/** @var Criteria $criteria */
$criteria = new Criteria();
$criteria->addFilter(new EqualsFilter('productId', $product->getId()));
$criteria->addAggregation(new CountAggregation('product-media-count', 'id'));
/** @var CountResult $productMediaCount */
$productMediaCount = $this->productMediaRepository->search($criteria, $event->getContext())
->getAggregations()->get('product-media-count');
$data['position'] = $productMediaCount->getCount();
$this->productMediaRepository->create([
$data
], $event->getContext());
$this->addLogStats('added', $uuid);
// Set coverId if added media is the first one
if (! $data['position']) {
/** @var Criteria $criteria */
$criteria = new Criteria();
$criteria->addFilter(new EqualsFilter('productId', $product->getId()));
$criteria->setLimit(1);
/** @var ProductMediaEntity $productMedia */
if (null != ($productMedia = $this->productMediaRepository->search($criteria, $event->getContext())->first())) {
$this->productRepository->update([
[
'id' => $product->getId(),
'coverId' => $productMedia->getId(),
]
], $event->getContext());
}
}
}
}
} else {
$this->logStats['notRecognized']++;
}
}
if ($this->systemConfigService->get('P2LabProductVideo.config.apiDebugMode')) {
/** @var ProductEntity $product */
foreach ($products as $product) {
/** @var array $uuids */
$uuids = $this->findProductPayloadUuids($product);
if (! $uuids) continue;
/** @var int $detected */
$detected = 0;
/** @var int $found */
$found = 0;
/** @var int $added */
$added = 0;
/** @var int $skipped */
$skipped = 0;
/** @var string $uuid */
foreach ($uuids as $uuid) {
$detected += $this->logStats['detected'][$uuid] ?? 0;
$found += $this->logStats['found'][$uuid] ?? 0;
$added += $this->logStats['added'][$uuid] ?? 0;
$skipped += $this->logStats['skipped'][$uuid] ?? 0;
}
$this->logger->debug(sprintf(
'In the product with ID "%s" has %d video(s) detected, %d video(s) found, %d video(s) added and %d video(s) skipped.',
$product->getId(),
$detected,
$found,
$added,
$skipped
));
}
if ($this->logStats['notRecognized']) {
$this->logger->debug(sprintf(
'There are not recognized %d video(s) without product ID.',
$this->logStats['notRecognized']
));
}
if ($this->logStats['ambiguous']) {
$this->logger->debug(sprintf(
'There are found %d ambiguous product(s) with the same product number or name. The video(s) will not be added.',
$this->logStats['ambiguous']
));
}
}
self::$isActiveEventOnProductWritten = true;
}
private function addLogStats($type, $uuid, $count = 1) {
if (!isset($this->logStats[$type][$uuid])) {
$this->logStats[$type][$uuid] = 0;
}
$this->logStats[$type][$uuid] += $count;
}
/**
* @param array $mediaIds
*/
public function deleteRelatedMedia(array $mediaIds): void
{
$this->connection->executeStatement('
DELETE FROM
`product_media`
WHERE
JSON_EXTRACT(`custom_fields`, "$.p2labProductVideo.mediaId") IN (:mediaIds)
',
['mediaIds' => $mediaIds],
['mediaIds' => Connection::PARAM_STR_ARRAY]
);
}
/**
* @param array $mediaIds
*/
public function deleteRelatedTranslations(array $mediaIds): void
{
/** @var array $conditions */
$conditions = array_map(function($mediaId){
return "JSON_SEARCH(`custom_fields`, 'one', " . $this->connection->quote($mediaId) . ", NULL, \"$.p2labProductVideo.translation.*\") IS NOT NULL";
}, $mediaIds);
/** @var string $sql */
$sql = sprintf('
SELECT
*
FROM
`product_media`
WHERE
%s
', implode(' OR ', $conditions));
/** @var array $rows */
$rows = $this->connection->fetchAllAssociative($sql);
/** @var array $row */
foreach ($rows as $row) {
/** @var array $customFields */
$customFields = json_decode( $row['custom_fields'], true );
$customFields['p2labProductVideo']['translation'] = array_filter($customFields['p2labProductVideo']['translation'], function($mediaId) use($mediaIds){
return ! in_array($mediaId, $mediaIds);
});
$this->connection->update('product_media', [
'custom_fields' => json_encode($customFields),
], [
'id' => $row['id'],
]);
}
}
/**
* @param EntityDeletedEvent $event
* @return bool
*/
public function onMediaDeleted(EntityDeletedEvent $event): bool
{
/** @var array $mediaIds */
$mediaIds = $event->getIds();
$this->deleteRelatedMedia($mediaIds);
$this->deleteRelatedTranslations($mediaIds);
return true;
}
}