ThumbnailService   F
last analyzed

Complexity

Total Complexity 84

Size/Duplication

Total Lines 490
Duplicated Lines 0 %

Importance

Changes 0
Metric Value
eloc 234
dl 0
loc 490
rs 2
c 0
b 0
f 0
wmc 84

16 Methods

Rating   Name   Duplication   Size   Complexity  
C getImageResource() 0 44 12
A ensureConfigIsLoaded() 0 17 3
A getFileSystem() 0 7 2
A __construct() 0 9 1
B calculateThumbnailSize() 0 50 10
B updateThumbnails() 0 62 11
A isSameDimension() 0 4 2
A createNewImage() 0 30 3
B generateAndSave() 0 73 8
B generate() 0 61 11
A deleteAssociatedThumbnails() 0 11 2
A getOriginalImageSize() 0 5 1
A deleteThumbnails() 0 3 1
A mediaCanHaveThumbnails() 0 17 5
B writeThumbnail() 0 33 8
A thumbnailsAreGeneratable() 0 6 4

How to fix   Complexity   

Complex Class

Complex classes like ThumbnailService often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use ThumbnailService, and based on these observations, apply Extract Interface, too.

1
<?php declare(strict_types=1);
2
3
namespace Shopware\Core\Content\Media\Thumbnail;
4
5
use Doctrine\DBAL\ArrayParameterType;
6
use Doctrine\DBAL\Connection;
7
use League\Flysystem\FilesystemOperator;
8
use Shopware\Core\Content\Media\Aggregate\MediaFolder\MediaFolderEntity;
9
use Shopware\Core\Content\Media\Aggregate\MediaFolderConfiguration\MediaFolderConfigurationEntity;
10
use Shopware\Core\Content\Media\Aggregate\MediaThumbnail\MediaThumbnailCollection;
11
use Shopware\Core\Content\Media\Aggregate\MediaThumbnail\MediaThumbnailEntity;
12
use Shopware\Core\Content\Media\Aggregate\MediaThumbnailSize\MediaThumbnailSizeCollection;
13
use Shopware\Core\Content\Media\Aggregate\MediaThumbnailSize\MediaThumbnailSizeEntity;
14
use Shopware\Core\Content\Media\Core\Event\UpdateThumbnailPathEvent;
15
use Shopware\Core\Content\Media\DataAbstractionLayer\MediaIndexingMessage;
16
use Shopware\Core\Content\Media\MediaCollection;
17
use Shopware\Core\Content\Media\MediaEntity;
18
use Shopware\Core\Content\Media\MediaException;
19
use Shopware\Core\Content\Media\MediaType\ImageType;
20
use Shopware\Core\Content\Media\MediaType\MediaType;
21
use Shopware\Core\Content\Media\Subscriber\MediaDeletionSubscriber;
22
use Shopware\Core\Framework\Context;
23
use Shopware\Core\Framework\DataAbstractionLayer\EntityRepository;
24
use Shopware\Core\Framework\DataAbstractionLayer\Indexing\EntityIndexer;
25
use Shopware\Core\Framework\DataAbstractionLayer\Indexing\EntityIndexerRegistry;
26
use Shopware\Core\Framework\DataAbstractionLayer\Search\Criteria;
27
use Shopware\Core\Framework\Log\Package;
28
use Shopware\Core\Framework\Uuid\Uuid;
29
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
30
31
#[Package('buyers-experience')]
32
class ThumbnailService
33
{
34
    /**
35
     * @internal
36
     */
37
    public function __construct(
38
        private readonly EntityRepository $thumbnailRepository,
39
        private readonly FilesystemOperator $filesystemPublic,
40
        private readonly FilesystemOperator $filesystemPrivate,
41
        private readonly EntityRepository $mediaFolderRepository,
42
        private readonly EventDispatcherInterface $dispatcher,
43
        private readonly EntityIndexer $indexer,
44
        private readonly Connection $connection
45
    ) {
46
    }
47
48
    public function generate(MediaCollection $collection, Context $context): int
49
    {
50
        $delete = [];
51
52
        $generate = [];
53
54
        foreach ($collection as $media) {
55
            if ($media->getThumbnails() === null) {
56
                throw MediaException::thumbnailAssociationNotLoaded();
57
            }
58
59
            if (!$this->mediaCanHaveThumbnails($media, $context)) {
60
                $delete = [...$delete, ...$media->getThumbnails()->getIds()];
61
62
                continue;
63
            }
64
65
            $mediaFolder = $media->getMediaFolder();
66
            if ($mediaFolder === null) {
67
                continue;
68
            }
69
70
            $config = $mediaFolder->getConfiguration();
71
            if ($config === null) {
72
                continue;
73
            }
74
75
            $delete = [...$delete, ...$media->getThumbnails()->getIds()];
76
77
            $generate[] = $media;
78
        }
79
80
        // disable media indexing to trigger it once after processing all thumbnails
81
        $context->addState(EntityIndexerRegistry::DISABLE_INDEXING);
82
83
        if (!empty($delete)) {
84
            $context->addState(MediaDeletionSubscriber::SYNCHRONE_FILE_DELETE);
85
86
            $delete = \array_values(\array_map(fn (string $id) => ['id' => $id], $delete));
87
88
            $this->thumbnailRepository->delete($delete, $context);
89
        }
90
91
        $updates = [];
92
        foreach ($generate as $media) {
93
            if ($media->getMediaFolder() === null || $media->getMediaFolder()->getConfiguration() === null) {
94
                continue;
95
            }
96
97
            $config = $media->getMediaFolder()->getConfiguration();
98
99
            $thumbnails = $this->generateAndSave($media, $config, $context, $config->getMediaThumbnailSizes());
100
101
            foreach ($thumbnails as $thumbnail) {
102
                $updates[] = $thumbnail;
103
            }
104
        }
105
106
        $this->indexer->handle(new MediaIndexingMessage($collection->getIds()));
107
108
        return \count($updates);
109
    }
110
111
    /**
112
     * @throws MediaException
113
     */
114
    public function updateThumbnails(MediaEntity $media, Context $context, bool $strict): int
115
    {
116
        if (!$this->mediaCanHaveThumbnails($media, $context)) {
117
            $this->deleteAssociatedThumbnails($media, $context);
118
119
            return 0;
120
        }
121
122
        $mediaFolder = $media->getMediaFolder();
123
        if ($mediaFolder === null) {
124
            return 0;
125
        }
126
127
        $config = $mediaFolder->getConfiguration();
128
        if ($config === null) {
129
            return 0;
130
        }
131
132
        $strict = \func_get_args()[2] ?? false;
133
134
        if ($config->getMediaThumbnailSizes() === null) {
135
            return 0;
136
        }
137
        if ($media->getThumbnails() === null) {
138
            return 0;
139
        }
140
141
        $toBeCreatedSizes = new MediaThumbnailSizeCollection($config->getMediaThumbnailSizes()->getElements());
142
        $toBeDeletedThumbnails = new MediaThumbnailCollection($media->getThumbnails()->getElements());
143
144
        foreach ($toBeCreatedSizes as $thumbnailSize) {
145
            foreach ($toBeDeletedThumbnails as $thumbnail) {
146
                if (!$this->isSameDimension($thumbnail, $thumbnailSize)) {
147
                    continue;
148
                }
149
150
                if ($strict === true && !$this->getFileSystem($media)->fileExists($thumbnail->getPath())) {
151
                    continue;
152
                }
153
154
                $toBeDeletedThumbnails->remove($thumbnail->getId());
155
                $toBeCreatedSizes->remove($thumbnailSize->getId());
156
157
                continue 2;
158
            }
159
        }
160
161
        $delete = \array_values(\array_map(static fn (string $id) => ['id' => $id], $toBeDeletedThumbnails->getIds()));
162
163
        $update = $this->connection->transactional(function () use ($delete, $media, $config, $context, $toBeCreatedSizes): array {
164
            return $context->state(function () use ($delete, $media, $config, $context, $toBeCreatedSizes): array {
165
                $this->thumbnailRepository->delete($delete, $context);
166
167
                $updated = $this->generateAndSave($media, $config, $context, $toBeCreatedSizes);
168
169
                $this->indexer->handle(new MediaIndexingMessage([$media->getId()]));
170
171
                return $updated;
172
            }, EntityIndexerRegistry::DISABLE_INDEXING);
173
        });
174
175
        return \count($update);
176
    }
177
178
    public function deleteThumbnails(MediaEntity $media, Context $context): void
179
    {
180
        $this->deleteAssociatedThumbnails($media, $context);
181
    }
182
183
    /**
184
     * @return array<array{id:string, mediaId:string, width:int, height:int}>
185
     */
186
    private function generateAndSave(MediaEntity $media, MediaFolderConfigurationEntity $config, Context $context, ?MediaThumbnailSizeCollection $sizes): array
187
    {
188
        if ($sizes === null || $sizes->count() === 0) {
189
            return [];
190
        }
191
192
        $image = $this->getImageResource($media);
193
194
        $imageSize = $this->getOriginalImageSize($image);
195
196
        $records = [];
197
198
        $type = $media->getMediaType();
199
        if ($type === null) {
200
            throw MediaException::mediaTypeNotLoaded($media->getId());
201
        }
202
203
        $mapped = [];
204
        foreach ($sizes as $size) {
205
            $id = Uuid::randomHex();
206
207
            $mapped[$size->getId()] = $id;
208
209
            $records[] = [
210
                'id' => $id,
211
                'mediaId' => $media->getId(),
212
                'width' => $size->getWidth(),
213
                'height' => $size->getHeight(),
214
            ];
215
        }
216
217
        // write thumbnail records to trigger path generation afterward
218
        $context->scope(Context::SYSTEM_SCOPE, function ($context) use ($records): void {
219
            $context->addState(EntityIndexerRegistry::DISABLE_INDEXING);
220
221
            $this->thumbnailRepository->create($records, $context);
222
        });
223
224
        $ids = \array_column($records, 'id');
225
226
        // triggers the path generation for the persisted thumbnails
227
        $this->dispatcher->dispatch(new UpdateThumbnailPathEvent($ids));
228
229
        // create hash map for easy path access
230
        $paths = $this->connection->fetchAllKeyValue(
231
            'SELECT LOWER(HEX(id)), path FROM media_thumbnail WHERE id IN (:ids)',
232
            ['ids' => Uuid::fromHexToBytesList($ids)],
233
            ['ids' => ArrayParameterType::STRING]
234
        );
235
236
        try {
237
            foreach ($sizes as $size) {
238
                $id = $mapped[$size->getId()];
239
240
                $thumbnailSize = $this->calculateThumbnailSize($imageSize, $size, $config);
241
242
                $thumbnail = $this->createNewImage($image, $type, $imageSize, $thumbnailSize);
243
244
                $path = $paths[$id];
245
246
                $this->writeThumbnail($thumbnail, $media, $path, $config->getThumbnailQuality());
247
248
                $fileSystem = $this->getFileSystem($media);
249
                if ($imageSize === $thumbnailSize && $fileSystem->fileSize($media->getPath()) < $fileSystem->fileSize($path)) {
250
                    // write file to file system
251
                    $fileSystem->write($path, $fileSystem->read($media->getPath()));
252
                }
253
254
                imagedestroy($thumbnail);
255
            }
256
            imagedestroy($image);
257
        } finally {
258
            return $records;
259
        }
260
    }
261
262
    private function ensureConfigIsLoaded(MediaEntity $media, Context $context): void
263
    {
264
        $mediaFolderId = $media->getMediaFolderId();
265
        if ($mediaFolderId === null) {
266
            return;
267
        }
268
269
        if ($media->getMediaFolder() !== null) {
270
            return;
271
        }
272
273
        $criteria = new Criteria([$mediaFolderId]);
274
        $criteria->addAssociation('configuration.mediaThumbnailSizes');
275
276
        /** @var MediaFolderEntity $folder */
277
        $folder = $this->mediaFolderRepository->search($criteria, $context)->get($mediaFolderId);
278
        $media->setMediaFolder($folder);
279
    }
280
281
    private function getImageResource(MediaEntity $media): \GdImage
282
    {
283
        $filePath = $media->getPath();
284
285
        /** @var string $file */
286
        $file = $this->getFileSystem($media)->read($filePath);
287
        $image = @imagecreatefromstring($file);
288
        if ($image === false) {
289
            throw MediaException::thumbnailNotSupported($media->getId());
290
        }
291
292
        if (\function_exists('exif_read_data')) {
293
            /** @var resource $stream */
294
            $stream = fopen('php://memory', 'r+b');
295
296
            try {
297
                // use in-memory stream to read the EXIF-metadata,
298
                // to avoid downloading the image twice from a remote filesystem
299
                fwrite($stream, $file);
300
                rewind($stream);
301
302
                $exif = @exif_read_data($stream);
303
304
                if ($exif !== false) {
305
                    if (!empty($exif['Orientation']) && $exif['Orientation'] === 8) {
306
                        $image = imagerotate($image, 90, 0);
307
                    } elseif (!empty($exif['Orientation']) && $exif['Orientation'] === 3) {
308
                        $image = imagerotate($image, 180, 0);
309
                    } elseif (!empty($exif['Orientation']) && $exif['Orientation'] === 6) {
310
                        $image = imagerotate($image, -90, 0);
311
                    }
312
                }
313
            } catch (\Exception) {
314
                // Ignore.
315
            } finally {
316
                fclose($stream);
317
            }
318
        }
319
320
        if ($image === false) {
321
            throw MediaException::thumbnailNotSupported($media->getId());
322
        }
323
324
        return $image;
325
    }
326
327
    /**
328
     * @return array{width: int, height: int}
329
     */
330
    private function getOriginalImageSize(\GdImage $image): array
331
    {
332
        return [
333
            'width' => imagesx($image),
334
            'height' => imagesy($image),
335
        ];
336
    }
337
338
    /**
339
     * @param array{width: int, height: int} $imageSize
340
     *
341
     * @return array{width: int, height: int}
342
     */
343
    private function calculateThumbnailSize(
344
        array $imageSize,
345
        MediaThumbnailSizeEntity $preferredThumbnailSize,
346
        MediaFolderConfigurationEntity $config
347
    ): array {
348
        if (!$config->getKeepAspectRatio() || $preferredThumbnailSize->getWidth() !== $preferredThumbnailSize->getHeight()) {
349
            $calculatedWidth = $preferredThumbnailSize->getWidth();
350
            $calculatedHeight = $preferredThumbnailSize->getHeight();
351
352
            $useOriginalSizeInThumbnails = $imageSize['width'] < $calculatedWidth || $imageSize['height'] < $calculatedHeight;
353
354
            return $useOriginalSizeInThumbnails ? [
355
                'width' => $imageSize['width'],
356
                'height' => $imageSize['height'],
357
            ] : [
358
                'width' => $calculatedWidth,
359
                'height' => $calculatedHeight,
360
            ];
361
        }
362
363
        if ($imageSize['width'] >= $imageSize['height']) {
364
            $aspectRatio = $imageSize['height'] / $imageSize['width'];
365
366
            $calculatedWidth = $preferredThumbnailSize->getWidth();
367
            $calculatedHeight = (int) ceil($preferredThumbnailSize->getHeight() * $aspectRatio);
368
369
            $useOriginalSizeInThumbnails = $imageSize['width'] < $calculatedWidth || $imageSize['height'] < $calculatedHeight;
370
371
            return $useOriginalSizeInThumbnails ? [
372
                'width' => $imageSize['width'],
373
                'height' => $imageSize['height'],
374
            ] : [
375
                'width' => $calculatedWidth,
376
                'height' => $calculatedHeight,
377
            ];
378
        }
379
380
        $aspectRatio = $imageSize['width'] / $imageSize['height'];
381
382
        $calculatedWidth = (int) ceil($preferredThumbnailSize->getWidth() * $aspectRatio);
383
        $calculatedHeight = $preferredThumbnailSize->getHeight();
384
385
        $useOriginalSizeInThumbnails = $imageSize['width'] < $calculatedWidth || $imageSize['height'] < $calculatedHeight;
386
387
        return $useOriginalSizeInThumbnails ? [
388
            'width' => $imageSize['width'],
389
            'height' => $imageSize['height'],
390
        ] : [
391
            'width' => $calculatedWidth,
392
            'height' => $calculatedHeight,
393
        ];
394
    }
395
396
    /**
397
     * @param array{width: int, height: int} $originalImageSize
398
     * @param array{width: int, height: int} $thumbnailSize
399
     */
400
    private function createNewImage(\GdImage $mediaImage, MediaType $type, array $originalImageSize, array $thumbnailSize): \GdImage
401
    {
402
        $thumbnail = imagecreatetruecolor($thumbnailSize['width'], $thumbnailSize['height']);
403
404
        if ($thumbnail === false) {
405
            throw MediaException::cannotCreateImage();
406
        }
407
408
        if (!$type->is(ImageType::TRANSPARENT)) {
409
            $colorWhite = (int) imagecolorallocate($thumbnail, 255, 255, 255);
410
            imagefill($thumbnail, 0, 0, $colorWhite);
411
        } else {
412
            imagealphablending($thumbnail, false);
413
        }
414
415
        imagesavealpha($thumbnail, true);
416
        imagecopyresampled(
417
            $thumbnail,
418
            $mediaImage,
419
            0,
420
            0,
421
            0,
422
            0,
423
            $thumbnailSize['width'],
424
            $thumbnailSize['height'],
425
            $originalImageSize['width'],
426
            $originalImageSize['height']
427
        );
428
429
        return $thumbnail;
430
    }
431
432
    private function writeThumbnail(\GdImage $thumbnail, MediaEntity $media, string $url, int $quality): void
433
    {
434
        ob_start();
435
        switch ($media->getMimeType()) {
436
            case 'image/png':
437
                imagepng($thumbnail);
438
439
                break;
440
            case 'image/gif':
441
                imagegif($thumbnail);
442
443
                break;
444
            case 'image/jpg':
445
            case 'image/jpeg':
446
                imagejpeg($thumbnail, null, $quality);
447
448
                break;
449
            case 'image/webp':
450
                if (!\function_exists('imagewebp')) {
451
                    throw MediaException::thumbnailCouldNotBeSaved($url);
452
                }
453
454
                imagewebp($thumbnail, null, $quality);
455
456
                break;
457
        }
458
        $imageFile = ob_get_contents();
459
        ob_end_clean();
460
461
        try {
462
            $this->getFileSystem($media)->write($url, (string) $imageFile);
463
        } catch (\Exception) {
464
            throw MediaException::thumbnailCouldNotBeSaved($url);
465
        }
466
    }
467
468
    private function mediaCanHaveThumbnails(MediaEntity $media, Context $context): bool
469
    {
470
        if (!$media->hasFile()) {
471
            return false;
472
        }
473
474
        if (!$this->thumbnailsAreGeneratable($media)) {
475
            return false;
476
        }
477
478
        $this->ensureConfigIsLoaded($media, $context);
479
480
        if ($media->getMediaFolder() === null || $media->getMediaFolder()->getConfiguration() === null) {
481
            return false;
482
        }
483
484
        return $media->getMediaFolder()->getConfiguration()->getCreateThumbnails();
485
    }
486
487
    private function thumbnailsAreGeneratable(MediaEntity $media): bool
488
    {
489
        return $media->getMediaType() instanceof ImageType
490
            && !$media->getMediaType()->is(ImageType::VECTOR_GRAPHIC)
491
            && !$media->getMediaType()->is(ImageType::ANIMATED)
492
            && !$media->getMediaType()->is(ImageType::ICON);
493
    }
494
495
    private function deleteAssociatedThumbnails(MediaEntity $media, Context $context): void
496
    {
497
        if (!$media->getThumbnails()) {
498
            throw MediaException::mediaContainsNoThumbnails();
499
        }
500
501
        $delete = $media->getThumbnails()->getIds();
502
503
        $delete = \array_values(\array_map(static fn (string $id) => ['id' => $id], $delete));
504
505
        $this->thumbnailRepository->delete($delete, $context);
506
    }
507
508
    private function getFileSystem(MediaEntity $media): FilesystemOperator
509
    {
510
        if ($media->isPrivate()) {
511
            return $this->filesystemPrivate;
512
        }
513
514
        return $this->filesystemPublic;
515
    }
516
517
    private function isSameDimension(MediaThumbnailEntity $thumbnail, MediaThumbnailSizeEntity $thumbnailSize): bool
518
    {
519
        return $thumbnail->getWidth() === $thumbnailSize->getWidth()
520
            && $thumbnail->getHeight() === $thumbnailSize->getHeight();
521
    }
522
}
523