Passed
Push — master ( ce9a66...c3a2b5 )
by Pauli
02:52
created

CoverHelper   F

Complexity

Total Complexity 69

Size/Duplication

Total Lines 436
Duplicated Lines 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
eloc 215
dl 0
loc 436
rs 2.88
c 3
b 0
f 0
wmc 69

18 Methods

Rating   Name   Duplication   Size   Complexity  
A getCoverFromCache() 0 12 3
A readCover() 0 21 6
B addCoverToCache() 0 30 6
A removeAlbumCoverFromCache() 0 2 1
B scaleDownAndCrop() 0 52 9
A getHashKey() 0 9 4
A getPlaceholder() 0 9 3
A getAllCachedAlbumCoverHashes() 0 9 2
A autoDetectMime() 0 2 1
A getCoverAndHash() 0 16 4
A readCoverFromLocalFile() 0 27 6
A createMosaic() 0 45 5
A createAccessToken() 0 6 1
A getUserForAccessToken() 0 9 3
A removeArtistCoverFromCache() 0 2 1
B getCover() 0 25 9
A getCoverMosaic() 0 8 3
A __construct() 0 15 2

How to fix   Complexity   

Complex Class

Complex classes like CoverHelper 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 CoverHelper, and based on these observations, apply Extract Interface, too.

1
<?php declare(strict_types=1);
2
3
/**
4
 * ownCloud - Music app
5
 *
6
 * This file is licensed under the Affero General Public License version 3 or
7
 * later. See the COPYING file.
8
 *
9
 * @author Pauli Järvinen <[email protected]>
10
 * @copyright Pauli Järvinen 2017 - 2024
11
 */
12
13
namespace OCA\Music\Utility;
14
15
use OCA\Music\AppFramework\Core\Logger;
16
use OCA\Music\AppFramework\Db\UniqueConstraintViolationException;
17
use OCA\Music\BusinessLayer\AlbumBusinessLayer;
18
use OCA\Music\Db\Album;
19
use OCA\Music\Db\Artist;
20
use OCA\Music\Db\Cache;
21
use OCA\Music\Db\Entity;
22
use OCA\Music\Db\PodcastChannel;
23
use OCA\Music\Db\Playlist;
24
use OCA\Music\Db\RadioStation;
25
26
use OCP\Files\Folder;
27
use OCP\Files\File;
28
29
use OCP\IConfig;
30
use OCP\IL10N;
31
32
/**
33
 * utility to get cover image for album
34
 */
35
class CoverHelper {
36
	private Extractor $extractor;
37
	private Cache $cache;
38
	private AlbumBusinessLayer $albumBusinessLayer;
39
	private int $coverSize;
40
	private IL10N $l10n;
41
	private Logger $logger;
42
43
	const MAX_SIZE_TO_CACHE = 102400;
44
	const DO_NOT_CROP_OR_SCALE = -1;
45
46
	public function __construct(
47
			Extractor $extractor,
48
			Cache $cache,
49
			AlbumBusinessLayer $albumBusinessLayer,
50
			IConfig $config,
51
			IL10N $l10n,
52
			Logger $logger) {
53
		$this->extractor = $extractor;
54
		$this->cache = $cache;
55
		$this->albumBusinessLayer = $albumBusinessLayer;
56
		$this->l10n = $l10n;
57
		$this->logger = $logger;
58
59
		// Read the cover size to use from config.php or use the default
60
		$this->coverSize = \intval($config->getSystemValue('music.cover_size')) ?: 380;
61
	}
62
63
	/**
64
	 * Get cover image of an album, an artist, a podcast, or a playlist.
65
	 * Returns a generated placeholder in case there's no art set.
66
	 *
67
	 * @param int|null $size Desired (max) image size, null to use the default.
68
	 *                       Special value DO_NOT_CROP_OR_SCALE can be used to opt out of
69
	 *                       scaling and cropping altogether.
70
	 * @return array|null Image data in format accepted by \OCA\Music\Http\FileResponse
71
	 */
72
	public function getCover(Entity $entity, string $userId, Folder $rootFolder, int $size=null, bool $allowPlaceholder=true) : ?array {
73
		if ($entity instanceof Playlist) {
74
			$trackIds = $entity->getTrackIdsAsArray();
75
			$albums = $this->albumBusinessLayer->findAlbumsWithCoversForTracks($trackIds, $userId, 4);
76
			$result = $this->getCoverMosaic($albums, $userId, $rootFolder);
77
		} elseif ($entity instanceof RadioStation) {
78
			$result = null; // only placeholders supported for radio
79
		} elseif ($entity instanceof Album || $entity instanceof Artist || $entity instanceof PodcastChannel) {
80
			if ($size !== null) {
81
				// Skip using cache in case the cover is requested in specific size
82
				$result = $this->readCover($entity, $rootFolder, $size);
83
			} else {
84
				$dataAndHash = $this->getCoverAndHash($entity, $userId, $rootFolder);
85
				$result = $dataAndHash['data'];
86
			}
87
		} else {
88
			// only placeholder is supported for any other Entity type
89
			$result = null;
90
		}	
91
92
		if ($result === null && $allowPlaceholder) {
93
			$result = $this->getPlaceholder($entity, $size);
94
		}
95
96
		return $result;
97
	}
98
99
	public function getCoverMosaic(array $entities, string $userId, Folder $rootFolder, int $size=null) : ?array {
100
		if (\count($entities) === 0) {
101
			return null;
102
		} elseif (\count($entities) === 1) {
103
			return $this->getCover($entities[0], $userId, $rootFolder, $size);
104
		} else {
105
			$covers = \array_map(fn($entity) => $this->getCover($entity, $userId, $rootFolder), $entities);
106
			return $this->createMosaic($covers, $size);
107
		}
108
	}
109
110
	/**
111
	 * Get cover image of an album or and artist along with the image's hash
112
	 *
113
	 * The hash is non-null only in case the cover is/was cached.
114
	 *
115
	 * @param Album|Artist|PodcastChannel $entity
116
	 * @param string $userId
117
	 * @param Folder $rootFolder
118
	 * @return array Dictionary with keys 'data' and 'hash'
119
	 */
120
	public function getCoverAndHash($entity, string $userId, Folder $rootFolder) : array {
121
		$hash = $this->cache->get($userId, self::getHashKey($entity));
122
		$data = null;
123
124
		if ($hash !== null) {
125
			$data = $this->getCoverFromCache($hash, $userId);
126
		}
127
		if ($data === null) {
128
			$hash = null;
129
			$data = $this->readCover($entity, $rootFolder, $this->coverSize);
130
			if ($data !== null) {
131
				$hash = $this->addCoverToCache($entity, $userId, $data);
132
			}
133
		}
134
135
		return ['data' => $data, 'hash' => $hash];
136
	}
137
138
	/**
139
	 * Get all album cover hashes for one user.
140
	 * @param string $userId
141
	 * @return array with album IDs as keys and hashes as values
142
	 */
143
	public function getAllCachedAlbumCoverHashes(string $userId) : array {
144
		$rows = $this->cache->getAll($userId, 'album_cover_hash_');
145
		$hashes = [];
146
		$prefixLen = \strlen('album_cover_hash_');
147
		foreach ($rows as $row) {
148
			$albumId = \substr($row['key'], $prefixLen);
149
			$hashes[$albumId] = $row['data'];
150
		}
151
		return $hashes;
152
	}
153
154
	/**
155
	 * Get cover image with given hash from the cache
156
	 *
157
	 * @param string $hash
158
	 * @param string $userId
159
	 * @param bool $asBase64
160
	 * @return array|null Image data in format accepted by \OCA\Music\Http\FileResponse
161
	 */
162
	public function getCoverFromCache(string $hash, string $userId, bool $asBase64 = false) : ?array {
163
		$cached = $this->cache->get($userId, 'cover_' . $hash);
164
		if ($cached !== null) {
165
			$delimPos = \strpos($cached, '|');
166
			$mime = \substr($cached, 0, $delimPos);
167
			$content = \substr($cached, $delimPos + 1);
168
			if (!$asBase64) {
169
				$content = \base64_decode($content);
170
			}
171
			return ['mimetype' => $mime, 'content' => $content];
172
		}
173
		return null;
174
	}
175
176
	private function getPlaceholder(Entity $entity, ?int $size) : array {
177
		$name = $entity->getNameString($this->l10n);
178
		if (\method_exists($entity, 'getAlbumArtistNameString')) {
179
			$seed = $entity->/** @scrutinizer ignore-call */getAlbumArtistNameString($this->l10n) . $name;
180
		} else {
181
			$seed = $name;
182
		}
183
		$size = $size > 0 ? (int)$size : $this->coverSize;
184
		return PlaceholderImage::generateForResponse($name, $seed, $size);
185
	}
186
187
	/**
188
	 * Cache the given cover image data
189
	 * @param Album|Artist|PodcastChannel $entity
190
	 * @param string $userId
191
	 * @param array $coverData
192
	 * @return string|null Hash of the cached cover
193
	 */
194
	private function addCoverToCache(Entity $entity, string $userId, array $coverData) : ?string {
195
		$mime = $coverData['mimetype'];
196
		$content = $coverData['content'];
197
		$hash = null;
198
		$hashKey = self::getHashKey($entity);
199
200
		if ($mime && $content) {
201
			$size = \strlen($content);
202
			if ($size < self::MAX_SIZE_TO_CACHE) {
203
				$hash = \hash('md5', $content);
204
				// cache the data with hash as a key
205
				try {
206
					$this->cache->add($userId, 'cover_' . $hash, $mime . '|' . \base64_encode($content));
207
				} catch (UniqueConstraintViolationException $ex) {
208
					$this->logger->log("Cover with hash $hash is already cached", 'debug');
209
				}
210
				// cache the hash with hashKey as a key
211
				try {
212
					$this->cache->add($userId, $hashKey, $hash);
213
				} catch (UniqueConstraintViolationException $ex) {
214
					$this->logger->log("Cover hash with key $hashKey is already cached", 'debug');
215
				}
216
				// collection.json needs to be regenerated the next time it's fetched
217
				$this->cache->remove($userId, 'collection');
218
			} else {
219
				$this->logger->log("Cover image of entity with key $hashKey is large ($size B), skip caching", 'debug');
220
			}
221
		}
222
223
		return $hash;
224
	}
225
226
	/**
227
	 * Remove album cover image from cache if it is there. Silently do nothing if there
228
	 * is no cached cover. All users are targeted if no $userId passed.
229
	 */
230
	public function removeAlbumCoverFromCache(int $albumId, string $userId=null) : void {
231
		$this->cache->remove($userId, 'album_cover_hash_' . $albumId);
232
	}
233
234
	/**
235
	 * Remove artist cover image from cache if it is there. Silently do nothing if there
236
	 * is no cached cover. All users are targeted if no $userId passed.
237
	 */
238
	public function removeArtistCoverFromCache(int $artistId, string $userId=null) : void {
239
		$this->cache->remove($userId, 'artist_cover_hash_' . $artistId);
240
	}
241
242
	/**
243
	 * Read cover image from the entity-specific file or URL and scale it unless the caller opts out of it
244
	 * @param Album|Artist|PodcastChannel $entity
245
	 * @param Folder $rootFolder
246
	 * @param int $size Maximum size for the image to read, larger images are scaled down.
247
	 *                  Special value DO_NOT_CROP_OR_SCALE can be used to opt out of
248
	 *                  scaling and cropping altogether.
249
	 * @return array|null Image data in format accepted by \OCA\Music\Http\FileResponse
250
	 */
251
	private function readCover($entity, Folder $rootFolder, int $size) : ?array {
252
		if ($entity instanceof PodcastChannel) {
253
			$image = HttpUtil::loadFromUrl($entity->getImageUrl())['content'];
254
			if ($image !== false) {
255
				$response = ['mimetype' => null, 'content' => $image];
256
			} else {
257
				$response = null;
258
			}
259
		} else {
260
			$response = $this->readCoverFromLocalFile($entity, $rootFolder);
261
		}
262
263
		if ($response !== null) {
264
			if ($size !== self::DO_NOT_CROP_OR_SCALE) {
265
				$response = $this->scaleDownAndCrop($response, $size);
266
			} elseif ($response['mimetype'] === null) {
267
				$response['mimetype'] = self::autoDetectMime($response['content']);
268
			}
269
		}
270
271
		return $response;
272
	}
273
274
	/**
275
	 * Read cover image from the file system
276
	 * @param Album|Artist $entity
277
	 * @param Folder $rootFolder
278
	 * @return array|null Image data in format accepted by \OCA\Music\Http\FileResponse
279
	 */
280
	private function readCoverFromLocalFile($entity, Folder $rootFolder) : ?array {
281
		$response = null;
282
283
		$coverId = $entity->getCoverFileId();
284
		if ($coverId > 0) {
285
			$node = $rootFolder->getById($coverId)[0] ?? null;
286
			if ($node instanceof File) {
287
				$mime = $node->getMimeType();
288
289
				if (\strpos($mime, 'audio') === 0) { // embedded cover image
290
					$cover = $this->extractor->parseEmbeddedCoverArt($node); // TODO: currently only album cover supported
291
292
					if ($cover !== null) {
293
						$response = ['mimetype' => $cover['image_mime'], 'content' => $cover['data']];
294
					}
295
				} else { // separate image file
296
					$response = ['mimetype' => $mime, 'content' => $node->getContent()];
297
				}
298
			}
299
300
			if ($response === null) {
301
				$class = \get_class($entity);
302
				$this->logger->log("Requested cover not found for $class entity {$entity->getId()}, coverId=$coverId", 'error');
303
			}
304
		}
305
306
		return $response;
307
	}
308
309
	/**
310
	 * Scale down images to reduce size and crop to square shape
311
	 *
312
	 * If one of the dimensions of the image is smaller than the maximum, then just
313
	 * crop to square shape but do not scale.
314
	 * @param array $image The image to be scaled down in format accepted by \OCA\Music\Http\FileResponse
315
	 * @param integer $maxSize The maximum size in pixels for the square shaped output
316
	 * @return array The processed image in format accepted by \OCA\Music\Http\FileResponse
317
	 */
318
	public function scaleDownAndCrop(array $image, int $maxSize) : array {
319
		$meta = \getimagesizefromstring($image['content']);
320
		$srcWidth = $meta[0];
321
		$srcHeight = $meta[1];
322
323
		// only process picture if it's larger than target size or not perfect square
324
		if ($srcWidth > $maxSize || $srcHeight > $maxSize || $srcWidth != $srcHeight) {
325
			$img = imagecreatefromstring($image['content']);
326
327
			if ($img === false) {
328
				$this->logger->log('Failed to open cover image for downscaling', 'warn');
329
			} else {
330
				$srcCropSize = \min($srcWidth, $srcHeight);
331
				$srcX = (int)(($srcWidth - $srcCropSize) / 2);
332
				$srcY = (int)(($srcHeight - $srcCropSize) / 2);
333
334
				$dstSize = \min($maxSize, $srcCropSize);
335
				$scaledImg = \imagecreatetruecolor($dstSize, $dstSize);
336
337
				if ($scaledImg === false) {
338
					$this->logger->log("Failed to create scaled image of size $dstSize x $dstSize", 'warn');
339
					\imagedestroy($img);
340
				} else {
341
					\imagecopyresampled($scaledImg, $img, 0, 0, $srcX, $srcY, $dstSize, $dstSize, $srcCropSize, $srcCropSize);
342
					\imagedestroy($img);
343
344
					\ob_start();
345
					\ob_clean();
346
					$image['mimetype'] = $meta['mime']; // override the supplied mime with the auto-detected one
347
					switch ($image['mimetype']) {
348
						case 'image/jpeg':
349
							imagejpeg($scaledImg, null, 75);
350
							$image['content'] = \ob_get_contents();
351
							break;
352
						case 'image/png':
353
							imagepng($scaledImg, null, 7, PNG_ALL_FILTERS);
354
							$image['content'] = \ob_get_contents();
355
							break;
356
						case 'image/gif':
357
							imagegif($scaledImg, null);
358
							$image['content'] = \ob_get_contents();
359
							break;
360
						default:
361
							$this->logger->log("Cover image type {$image['mimetype']} not supported for downscaling", 'warn');
362
							break;
363
					}
364
					\ob_end_clean();
365
					\imagedestroy($scaledImg);
366
				}
367
			}
368
		}
369
		return $image;
370
	}
371
372
	private function createMosaic(array $covers, ?int $size) : array {
373
		$size = $size ?: $this->coverSize;
374
		$pieceSize = $size/2;
375
		$mosaicImg = \imagecreatetruecolor($size, $size);
376
		if ($mosaicImg === false) {
377
			$this->logger->log("Failed to create mosaic image of size $size x $size", 'warn');
378
		}
379
		else {
380
			$scaleAndCopyPiece = function($pieceData, $dstImage, $dstX, $dstY, $dstSize) {
381
				$meta = \getimagesizefromstring($pieceData['content']);
382
				$srcWidth = $meta[0];
383
				$srcHeight = $meta[1];
384
385
				$piece = imagecreatefromstring($pieceData['content']);
386
387
				if ($piece === false) {
388
					$this->logger->log('Failed to open cover image to create a mosaic', 'warn');
389
				} else {
390
					\imagecopyresampled($dstImage, $piece, $dstX, $dstY, 0, 0, $dstSize, $dstSize, $srcWidth, $srcHeight);
391
					\imagedestroy($piece);
392
				}
393
			};
394
395
			$coordinates = [
396
				['x' => 0,			'y' => 0],			// top-left
397
				['x' => $pieceSize,	'y' => $pieceSize],	// bottom-right
398
				['x' => $pieceSize,	'y' => 0],			// top-right
399
				['x' => 0,			'y' => $pieceSize],	// bottom-left
400
			];
401
402
			$covers = \array_slice($covers, 0, 4);
403
			foreach ($covers as $i => $cover) {
404
				$scaleAndCopyPiece($cover, $mosaicImg, $coordinates[$i]['x'], $coordinates[$i]['y'], $pieceSize);
405
			}
406
		}
407
408
		$image = ['mimetype' => 'image/png'];
409
		\ob_start();
410
		\ob_clean();
411
		imagepng($mosaicImg, null, 7, PNG_ALL_FILTERS);
412
		$image['content'] = \ob_get_contents();
413
		\ob_end_clean();
414
		\imagedestroy($mosaicImg);
415
416
		return $image;
417
	}
418
419
	private static function autoDetectMime(string $imageContent) : string {
420
		return \getimagesizefromstring($imageContent)['mime'];
421
	}
422
423
	/**
424
	 * @throws \InvalidArgumentException if entity is not one of the expected types
425
	 */
426
	private static function getHashKey(Entity $entity) : string {
427
		if ($entity instanceof Album) {
428
			return 'album_cover_hash_' . $entity->getId();
429
		} elseif ($entity instanceof Artist) {
430
			return 'artist_cover_hash_' . $entity->getId();
431
		} elseif ($entity instanceof PodcastChannel) {
432
			return 'podcast_cover_hash' . $entity->getId();
433
		} else {
434
			throw new \InvalidArgumentException('Unexpected entity type');
435
		}
436
	}
437
438
	/**
439
	 * Create and store an access token which can be used to read cover images of a user.
440
	 * A user may have only one valid cover image access token at a time; the latest token
441
	 * always overwrites the previously obtained one.
442
	 *
443
	 * The reason this is needed is because the mediaSession in Firefox loads the cover images
444
	 * in a context where normal cookies and other standard request headers are not available.
445
	 * Hence, we need to provide the cover images as "public" resources, i.e. without requiring
446
	 * that the caller is logged in to the cloud. But still, we don't want to let just anyone
447
	 * load the user data. The solution is to use a temporary token which grants access just to
448
	 * the cover images. This token can be then sent as URL argument by the mediaSession.
449
	 */
450
	public function createAccessToken(string $userId) : string {
451
		$token = Random::secure(32);
452
		// It might be neater to use a dedicated DB table for this, but the generic cache table
453
		// will do, at least for now.
454
		$this->cache->set($userId, 'cover_access_token', $token);
455
		return $token;
456
	}
457
458
	/**
459
	 * @see CoverHelper::createAccessToken
460
	 * @throws \OutOfBoundsException if the token is not valid
461
	 */
462
	public function getUserForAccessToken(?string $token) : string {
463
		if ($token === null) {
464
			throw new \OutOfBoundsException('Cannot get user for a null token');
465
		}
466
		$userId = $this->cache->getOwner('cover_access_token', $token);
467
		if ($userId === null) {
468
			throw new \OutOfBoundsException('No userId found for the given token');
469
		}
470
		return $userId;
471
	}
472
}
473