custom/plugins/P2LabProductVideo/src/Subscriber/MediaSubscriber.php line 653

Open in your IDE?
  1. <?php declare(strict_types=1);
  2. namespace P2Lab\ProductVideo\Subscriber;
  3. use Monolog\Logger;
  4. use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityWrittenEvent;
  5. use Shopware\Core\Framework\DataAbstractionLayer\Event\EntityDeletedEvent;
  6. use Shopware\Core\Framework\DataAbstractionLayer\Search\Aggregation\Metric\CountAggregation;
  7. use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
  8. use Symfony\Component\EventDispatcher\EventSubscriberInterface;
  9. use Shopware\Core\Content\Media\MediaEvents;
  10. use Shopware\Core\Content\Product\ProductEvents;
  11. use Doctrine\DBAL\Connection;
  12. use Doctrine\DBAL\Query\QueryBuilder;
  13. use P2Lab\ProductVideo\Helper\EmbedVideoHelper;
  14. use Shopware\Core\Content\Media\File\FileFetcher;
  15. use Shopware\Core\Content\Media\File\FileSaver;
  16. use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
  17. use Shopware\Core\Content\Product\ProductCollection;
  18. use Shopware\Core\Content\Product\ProductEntity;
  19. use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
  20. use Shopware\Core\Framework\DataAbstractionLayer\EntityRepositoryInterface;
  21. use Shopware\Core\Framework\DataAbstractionLayer\Search\Filter\EqualsFilter;
  22. use Shopware\Core\System\SystemConfig\SystemConfigService;
  23. use Symfony\Component\HttpKernel\KernelEvents;
  24. use Symfony\Component\HttpFoundation\Request;
  25. use Symfony\Component\HttpKernel\Event\RequestEvent;
  26. use Shopware\Core\Framework\DataAbstractionLayer\Search\AggregationResult\Metric\CountResult;
  27. use Psr\Log\LoggerInterface;
  28. use Shopware\Core\Content\Product\Aggregate\ProductMedia\ProductMediaEntity;
  29. use Shopware\Core\Framework\Uuid\Uuid;
  30. class MediaSubscriber implements EventSubscriberInterface
  31. {
  32.     private Connection $connection;
  33.     private EntityRepositoryInterface $productRepository;
  34.     /** @var EntityRepositoryInterface */
  35.     private $mediaRepository;
  36.     /** @var EntityRepositoryInterface */
  37.     private EntityRepositoryInterface $productMediaRepository;
  38.     /** @var FileFetcher */
  39.     private FileFetcher $fileFetcher;
  40.     /** @var FileSaver */
  41.     private FileSaver $fileSaver;
  42.     /** @var EventDispatcherInterface */
  43.     private EventDispatcherInterface $eventDispatcher;
  44.     /** @var SystemConfigService */
  45.     private SystemConfigService $systemConfigService;
  46.     /** @var LoggerInterface */
  47.     private $logger;
  48.     /** @var bool */
  49.     private bool $isBulkApiEnabled false;
  50.     /** @var array */
  51.     private static $productPayloads = [];
  52.     /** @var bool */
  53.     private static bool $isActiveEventOnProductWritten true;
  54.     private array $logStats = [
  55.         'detected' => [],
  56.         'found' => [],
  57.         'skipped' => [],
  58.         'added' => [],
  59.         'notRecognized' => 0,
  60.         'ambiguous' => 0,
  61.     ];
  62.     /**
  63.      * @internal
  64.      */
  65.     public function __construct(
  66.         Connection $connection,
  67.         EntityRepositoryInterface $productRepository,
  68.         $mediaRepository,
  69.         EntityRepositoryInterface $productMediaRepository,
  70.         FileFetcher $fileFetcher,
  71.         FileSaver $fileSaver,
  72.         EventDispatcherInterface $eventDispatcher,
  73.         SystemConfigService $systemConfigService,
  74.         LoggerInterface $logger
  75.     )
  76.     {
  77.         $this->connection $connection;
  78.         $this->productRepository $productRepository;
  79.         $this->mediaRepository $mediaRepository;
  80.         $this->productMediaRepository $productMediaRepository;
  81.         $this->fileFetcher $fileFetcher;
  82.         $this->fileSaver $fileSaver;
  83.         $this->eventDispatcher $eventDispatcher;
  84.         $this->systemConfigService $systemConfigService;
  85.         $this->logger $logger;
  86.         $this->isBulkApiEnabled = (bool) $this->systemConfigService->get('P2LabProductVideo.config.bulkApiEnabled');
  87.     }
  88.     /**
  89.      * @return array
  90.      */
  91.     public static function getSubscribedEvents(): array
  92.     {
  93.         return [
  94.             KernelEvents::REQUEST => 'onKernelRequest',
  95.             MediaEvents::MEDIA_DELETED_EVENT => 'onMediaDeleted',
  96.             ProductEvents::PRODUCT_WRITTEN_EVENT => 'onProductWritten',
  97.         ];
  98.     }
  99.     
  100.     /**
  101.      * @param array<mixed> $array
  102.      */
  103.     private function isCollection(array $array): bool
  104.     {
  105.         return array_keys($array) === range(0\count($array) - 1);
  106.     }
  107.     /**
  108.      * @param RequestEvent $event
  109.      */
  110.     public function onKernelRequest(RequestEvent $event): void
  111.     {
  112.         if (! $this->isBulkApiEnabled) return;
  113.         /** @var Request $request */
  114.         $request $event->getRequest();
  115.         if ($request->getPathInfo() !== '/api/_action/sync' || ! $request->isMethod('POST')) return;
  116.         /** @var array $operation */
  117.         foreach ($request->request->all() as $operation) {
  118.             if (
  119.                 is_array($operation)
  120.                     &&
  121.                 isset($operation['action'])
  122.                     &&
  123.                 $operation['action'] === 'upsert'
  124.                     &&
  125.                 isset($operation['entity'])
  126.                     &&
  127.                 $operation['entity'] === 'product'
  128.                     &&
  129.                 isset($operation['payload'])
  130.                     &&
  131.                 is_array($operation['payload'])
  132.             ) {
  133.                 /** @var array $payload */
  134.                 foreach ($operation['payload'] as $payload) {
  135.                     if ( 
  136.                         is_array($payload
  137.                             &&
  138.                         isset($payload['p2labProductVideo'])
  139.                             &&
  140.                         is_array($payload['p2labProductVideo'])
  141.                     ) {
  142.                         /** @var array $p2labProductVideo */
  143.                         $p2labProductVideo = [];
  144.                         if ($this->isCollection($payload['p2labProductVideo'])) {
  145.                             $p2labProductVideo $payload['p2labProductVideo'];
  146.                         } else {
  147.                             $p2labProductVideo[] = $payload['p2labProductVideo'];
  148.                         }
  149.                         
  150.                         $p2labProductVideo array_filter($p2labProductVideo, function($video){
  151.                             return is_array($video) && isset($video['url']);
  152.                         });
  153.                         if ($p2labProductVideo) {
  154.                             /** @var array $productPayload */
  155.                             $productPayload = [
  156.                                 'uuid' => Uuid::randomHex(),
  157.                                 'productData' => [],
  158.                                 'videoData' => [
  159.                                     'p2labProductVideo' => $p2labProductVideo,
  160.                                 ],
  161.                             ];
  162.                             if (isset($payload['id'])) {
  163.                                 $productPayload['videoData']['id'] = $payload['id'];
  164.                             }
  165.                             if (!empty($payload['productNumber'])) {
  166.                                 $productPayload['productData']['productNumber'] = md5($payload['productNumber']);
  167.                             }
  168.                             if (!empty($payload['name'])) {
  169.                                 $productPayload['productData']['name'] = md5($payload['name']);
  170.                             }
  171.                             
  172.                             $this->addLogStats('detected'$productPayload['uuid'], count($p2labProductVideo));
  173.                             self::$productPayloads[] = $productPayload;
  174.                         }
  175.                     }
  176.                 }
  177.             }
  178.         }
  179.     }
  180.     private function findProductPayloadByData(array $productDatabool $notUsed true): array
  181.     {
  182.         /** @var array $foundKeys */
  183.         $foundKeys = [];
  184.         /** @var array $payload */
  185.         foreach (self::$productPayloads as $key => $payload) {
  186.             if (!isset($payload['productData'])) continue;
  187.             if ($notUsed && !empty($payload['used'])) continue;
  188.             /** @var int $theSame */
  189.             $theSame 0;
  190.             foreach ($payload['productData'] as $dataKey => $dataValue) {
  191.                 if (
  192.                     isset($productData[$dataKey])
  193.                         &&
  194.                     $productData[$dataKey] === $dataValue
  195.                 ) {
  196.                     $theSame++;
  197.                 }
  198.             }
  199.             if ($theSame && $theSame === count($productData)) {
  200.                 $foundKeys[] = $key;
  201.             }
  202.         }
  203.         return $foundKeys;
  204.     }
  205.     /**
  206.      * @param ProductEntity $product
  207.      * @return array
  208.      */
  209.     private function findProductPayloadUuids($product): array {
  210.         /** @var array $productPayloadKeys */
  211.         if (null === ($productPayloadKeys $this->findProductPayloadKeys($productfalse))) {
  212.             return [];
  213.         }
  214.         /** @var array $uuids */
  215.         $uuids = [];
  216.         /** @var int $key */
  217.         foreach ($productPayloadKeys as $key) {
  218.             $uuids[] = self::$productPayloads[$key]['uuid'];
  219.         }
  220.         return $uuids;
  221.     }
  222.     /**
  223.      * @param ProductEntity $product
  224.      * @return int|null
  225.      */
  226.     private function findProductPayloadKey($productbool $notUsed true): ?int {
  227.         /** @var array $foundKeys */
  228.         $foundKeys $this->findProductPayloadKeys($product$notUsed);
  229.         if (!$foundKeys) return null;
  230.         if (count($foundKeys) > 1) {
  231.             $this->logStats['ambiguous']++;
  232.             return null;
  233.         }
  234.         return $foundKeys[0];
  235.     }
  236.     /**
  237.      * @param ProductEntity $product
  238.      * @return array
  239.      */
  240.     private function findProductPayloadKeys($productbool $notUsed true): array {
  241.         /** @var array $foundKeys */
  242.         $foundKeys = [];
  243.         /** @var array $payload */
  244.         foreach (self::$productPayloads as $key => $payload) {
  245.             if (
  246.                 isset($payload['videoData']['id'])
  247.                     &&
  248.                 $payload['videoData']['id'] === $product->getId()
  249.             ) {
  250.                 if ($notUsed && !empty($payload['used'])) continue;
  251.                 $foundKeys[] = $key;
  252.             }
  253.         }
  254.         if (!$foundKeys) {
  255.             $foundKeys $this->findProductPayloadByData([
  256.                 'productNumber' => md5($product->getProductNumber()),
  257.             ], $notUsed);
  258.             if (!$foundKeys || count($foundKeys) > 1) {
  259.                 $foundKeys $this->findProductPayloadByData([
  260.                     'productNumber' => md5($product->getProductNumber()),
  261.                     'name' => md5($product->getName()),
  262.                 ], $notUsed);
  263.             }
  264.         }
  265.         
  266.         return $foundKeys;
  267.     }
  268.     /**
  269.      * @param array $productIds
  270.      * @return array
  271.      */
  272.     private function getProductsMediaUrls(array $productIds): array
  273.     {
  274.         if (! $productIds) return [];
  275.         /** @var QueryBuilder $query */
  276.         $query = new QueryBuilder($this->connection);
  277.         
  278.         $query
  279.             ->select('product_media.*')
  280.             ->from('product_media')
  281.             ->where('product_media.product_id IN (:productIds)')
  282.             ->andWhere("JSON_EXTRACT(`product_media`.`custom_fields`, '$.\"p2labProductVideo\"') IS NOT NULL")
  283.             ->setParameter('productIds'array_map(function($id){
  284.                 return hex2bin($id);
  285.             }, $productIds), Connection::PARAM_STR_ARRAY);
  286.         /** @var array $productMediaUrls */
  287.         $productMediaUrls = [];
  288.         /** @var array $rows */
  289.         $rows $query->execute()->fetchAllAssociative();
  290.         /** @var array $row */
  291.         foreach ($rows as $row) {
  292.             /** @var array $customFields */
  293.             $customFields json_decode$row['custom_fields'], true );
  294.             /** @var array $p2labProductVideo */
  295.             $p2labProductVideo $customFields['p2labProductVideo'];
  296.             if (! empty($p2labProductVideo['url'])) {
  297.                 $productMediaUrls[bin2hex($row['product_id'])][] = $p2labProductVideo['url'];
  298.             }
  299.         }
  300.         return $productMediaUrls;
  301.     }
  302.     /**
  303.      * @param EntityWrittenEvent $event
  304.      */
  305.     public function onProductWritten(EntityWrittenEvent $event): void
  306.     {
  307.         if (! $this->isBulkApiEnabled) return;
  308.         
  309.         /** @var array $productIds */
  310.         if (null == ($productIds $event->getIds())) return;
  311.         
  312.         if (! self::$isActiveEventOnProductWritten) return;
  313.         self::$isActiveEventOnProductWritten false;
  314.         
  315.         /** @var ProductCollection $products */
  316.         $products $this->productRepository->search(new Criteria($productIds), $event->getContext())->getEntities();
  317.         /** @var array $productsMediaUrls */
  318.         $productsMediaUrls $this->getProductsMediaUrls($productIds);
  319.         /** @var ProductEntity $product */
  320.         foreach ($products as $product) {
  321.             /** @var array $payloadKeys */
  322.             if (null != ($payloadKeys $this->findProductPayloadKeys($product))) {
  323.                 /** @var array $p2labProductVideos */
  324.                 $p2labProductVideos = [];
  325.                 /** @var array $uuids */
  326.                 $uuids = [];
  327.                 /** @var int $payloadKey */
  328.                 foreach ($payloadKeys as $payloadKey) {
  329.                     /** @var array $payload */
  330.                     $payload = & self::$productPayloads[$payloadKey];
  331.                     if (!empty($payload['used'])) continue;
  332.                     /** @var string $uuid */
  333.                     $uuid $payload['uuid'];
  334.                     $p2labProductVideos[$uuid] = $payload['videoData']['p2labProductVideo'];
  335.                     $payload['used'] = true;
  336.                     $uuids[] = $uuid;
  337.                     $this->addLogStats('found'$uuidcount($payload['videoData']['p2labProductVideo']));
  338.                 }
  339.                 /** @var array $video */
  340.                 foreach ($p2labProductVideos as $uuid => $videos) {
  341.                     /** @var array $video */
  342.                     foreach ($videos as $video) {
  343.                         if (empty($video['autoThumbnail'])) {
  344.                             if (empty($video['mediaId'])) {
  345.                                 $this->logger->error(sprintf(
  346.                                     "The product with ID '%s' has no 'p2labProductVideo.mediaId' or 'p2labProductVideo.autoThumbnail'."$product->getId())
  347.                                 );
  348.                                 $this->addLogStats('skipped'$uuid);
  349.                                 continue;
  350.                             }
  351.                         }
  352.                         if (empty($video['url'])) {
  353.                             $this->logger->error(sprintf(
  354.                                 "The product with ID '%s' has no 'p2labProductVideo.url'."$product->getId())
  355.                             );
  356.                             $this->addLogStats('skipped'$uuid);
  357.                             continue;
  358.                         }
  359.                             
  360.                         if (!EmbedVideoHelper::recognizeEmbedVideoType($video['url'])) {
  361.                             $this->logger->error(sprintf(
  362.                                 "The video source for the product with ID '%s' of '%s' is not recognized."$product->getId(), $video['url'])
  363.                             );
  364.                             $this->addLogStats('skipped'$uuid);
  365.                             
  366.                             continue;
  367.                         }
  368.                         if (isset($productsMediaUrls[$product->getId()])) {
  369.                             /** @var string $url */
  370.                             $url EmbedVideoHelper::normalizeEmbedVideoUrl($video['url']);
  371.                             if (in_array($url$productsMediaUrls[$product->getId()])) {
  372.                                 $this->logger->debug(sprintf(
  373.                                     'The video source "%s" already exists in the product with ID "%s" and has been skipped.'
  374.                                     $url
  375.                                     $product->getId()
  376.                                 ));
  377.                                 $this->addLogStats('skipped'$uuid);
  378.                                 continue;
  379.                             }
  380.                         }
  381.                         /** @var array $data */
  382.                         $data EmbedVideoHelper::upload(
  383.                             $this->mediaRepository,
  384.                             $this->fileFetcher,
  385.                             $this->fileSaver,
  386.                             $this->eventDispatcher,
  387.                             [
  388.                                 'productId' => $product->getId(),
  389.                                 'mediaId' => empty($video['mediaId']) ? null $video['mediaId'],
  390.                                 'customFields' => [
  391.                                     'p2labProductVideo' => [
  392.                                         'url' => $video['url'],
  393.                                     ],
  394.                                 ],                    
  395.                             ], 
  396.                             $event->getContext(),
  397.                             null,
  398.                             $this->logger
  399.                         );
  400.                         
  401.                         if (empty($data['mediaId'])) {
  402.                             $this->addLogStats('skipped'$uuid);
  403.                             continue;
  404.                         }
  405.                         /** @var Criteria $criteria */
  406.                         $criteria = new Criteria();
  407.                         $criteria->addFilter(new EqualsFilter('productId'$product->getId()));
  408.                         $criteria->addAggregation(new CountAggregation('product-media-count''id'));
  409.                         /** @var CountResult $productMediaCount */
  410.                         $productMediaCount $this->productMediaRepository->search($criteria$event->getContext())
  411.                             ->getAggregations()->get('product-media-count');
  412.                         $data['position'] = $productMediaCount->getCount();
  413.                         $this->productMediaRepository->create([
  414.                             $data
  415.                         ], $event->getContext());
  416.                         $this->addLogStats('added'$uuid);
  417.                         // Set coverId if added media is the first one
  418.                         if (! $data['position']) {
  419.                             /** @var Criteria $criteria */
  420.                             $criteria = new Criteria();
  421.                             $criteria->addFilter(new EqualsFilter('productId'$product->getId()));
  422.                             $criteria->setLimit(1);
  423.                             /** @var ProductMediaEntity $productMedia */
  424.                             if (null != ($productMedia $this->productMediaRepository->search($criteria$event->getContext())->first())) {
  425.                                 $this->productRepository->update([
  426.                                     [
  427.                                         'id' => $product->getId(),
  428.                                         'coverId' => $productMedia->getId(),
  429.                                     ]
  430.                                 ], $event->getContext());
  431.                             }
  432.                         }
  433.                     }
  434.                 }
  435.             } else {
  436.                 $this->logStats['notRecognized']++;
  437.             }
  438.         }
  439.         if ($this->systemConfigService->get('P2LabProductVideo.config.apiDebugMode')) {
  440.             /** @var ProductEntity $product */
  441.             foreach ($products as $product) {
  442.                 /** @var array $uuids */
  443.                 $uuids $this->findProductPayloadUuids($product);
  444.                 if (! $uuids) continue;
  445.                 /** @var int $detected */
  446.                 $detected 0;
  447.                 /** @var int $found */
  448.                 $found 0;
  449.                 /** @var int $added */
  450.                 $added 0;
  451.                 /** @var int $skipped */
  452.                 $skipped 0;
  453.                 /** @var string $uuid */
  454.                 foreach ($uuids as $uuid) {
  455.                     $detected += $this->logStats['detected'][$uuid] ?? 0;
  456.                     $found += $this->logStats['found'][$uuid] ?? 0;
  457.                     $added += $this->logStats['added'][$uuid] ?? 0;
  458.                     $skipped += $this->logStats['skipped'][$uuid] ?? 0;
  459.                 }
  460.                 $this->logger->debug(sprintf(
  461.                     'In the product with ID "%s" has %d video(s) detected, %d video(s) found, %d video(s) added and %d video(s) skipped.'
  462.                     $product->getId(), 
  463.                     $detected,
  464.                     $found,
  465.                     $added,
  466.                     $skipped
  467.                 ));
  468.             }
  469.             if ($this->logStats['notRecognized']) {
  470.                 $this->logger->debug(sprintf(
  471.                     'There are not recognized %d video(s) without product ID.',
  472.                     $this->logStats['notRecognized']
  473.                 ));
  474.             }
  475.             if ($this->logStats['ambiguous']) {
  476.                 $this->logger->debug(sprintf(
  477.                     'There are found %d ambiguous product(s) with the same product number or name. The video(s) will not be added.',
  478.                     $this->logStats['ambiguous']
  479.                 ));
  480.             }
  481.         }
  482.         self::$isActiveEventOnProductWritten true;
  483.     }
  484.     private function addLogStats($type$uuid$count 1) {
  485.         if (!isset($this->logStats[$type][$uuid])) {
  486.             $this->logStats[$type][$uuid] = 0;
  487.         }
  488.         $this->logStats[$type][$uuid] += $count;
  489.     }
  490.     /**
  491.      * @param array $mediaIds
  492.      */
  493.     public function deleteRelatedMedia(array $mediaIds): void
  494.     {
  495.         $this->connection->executeStatement('
  496.             DELETE FROM 
  497.                 `product_media`
  498.             WHERE 
  499.                 JSON_EXTRACT(`custom_fields`, "$.p2labProductVideo.mediaId") IN (:mediaIds)
  500.             ',
  501.             ['mediaIds' => $mediaIds],
  502.             ['mediaIds' => Connection::PARAM_STR_ARRAY]
  503.         );
  504.     }
  505.     /**
  506.      * @param array $mediaIds
  507.      */
  508.     public function deleteRelatedTranslations(array $mediaIds): void
  509.     {
  510.         /** @var array $conditions */
  511.         $conditions array_map(function($mediaId){
  512.             return "JSON_SEARCH(`custom_fields`, 'one', " $this->connection->quote($mediaId) . ", NULL, \"$.p2labProductVideo.translation.*\") IS NOT NULL";
  513.         }, $mediaIds);
  514.         /** @var string $sql */
  515.         $sql sprintf('
  516.             SELECT 
  517.                 *
  518.             FROM
  519.                 `product_media`
  520.             WHERE 
  521.                 %s
  522.         'implode(' OR '$conditions));
  523.         /** @var array $rows */
  524.         $rows $this->connection->fetchAllAssociative($sql);
  525.         /** @var array $row */
  526.         foreach ($rows as $row) {
  527.             /** @var array $customFields */
  528.             $customFields json_decode$row['custom_fields'], true );
  529.             $customFields['p2labProductVideo']['translation'] = array_filter($customFields['p2labProductVideo']['translation'], function($mediaId) use($mediaIds){
  530.                 return ! in_array($mediaId$mediaIds);
  531.             });
  532.             $this->connection->update('product_media', [
  533.                 'custom_fields' => json_encode($customFields),
  534.             ], [
  535.                 'id' => $row['id'],
  536.             ]);
  537.         }
  538.     }
  539.     /**
  540.      * @param EntityDeletedEvent $event
  541.      * @return bool
  542.      */
  543.     public function onMediaDeleted(EntityDeletedEvent $event): bool
  544.     {
  545.         /** @var array $mediaIds */
  546.         $mediaIds $event->getIds();
  547.         $this->deleteRelatedMedia($mediaIds);
  548.         $this->deleteRelatedTranslations($mediaIds);
  549.         return true;
  550.     }
  551. }