Passed
Push — master ( b806b4...5b46ab )
by Pauli
02:42
created

CoverHelper::getCoverMosaic()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 11
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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