CoverService   F
last analyzed

Complexity

Total Complexity 72

Size/Duplication

Total Lines 445
Duplicated Lines 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 220
c 1
b 0
f 0
dl 0
loc 445
rs 2.64
wmc 72

18 Methods

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

How to fix   Complexity   

Complex Class

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