Passed
Push — feature/786_podcasts ( 7b8be7...af6910 )
by Pauli
02:22
created

CoverHelper::autoDetectMime()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 2
rs 10
c 0
b 0
f 0
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\Db\Album;
18
use \OCA\Music\Db\Artist;
19
use \OCA\Music\Db\Cache;
20
use \OCA\Music\Db\PodcastChannel;
21
22
use \OCP\Files\Folder;
23
use \OCP\Files\File;
24
25
use \OCP\IConfig;
26
27
/**
28
 * utility to get cover image for album
29
 */
30
class CoverHelper {
31
	private $extractor;
32
	private $cache;
33
	private $coverSize;
34
	private $logger;
35
36
	const MAX_SIZE_TO_CACHE = 102400;
37
	const DO_NOT_CROP_OR_SCALE = -1;
38
39
	public function __construct(
40
			Extractor $extractor,
41
			Cache $cache,
42
			IConfig $config,
43
			Logger $logger) {
44
		$this->extractor = $extractor;
45
		$this->cache = $cache;
46
		$this->logger = $logger;
47
48
		// Read the cover size to use from config.php or use the default
49
		$this->coverSize = \intval($config->getSystemValue('music.cover_size')) ?: 380;
50
	}
51
52
	/**
53
	 * Get cover image of an album or and artist
54
	 *
55
	 * @param Album|Artist|PodcastChannel $entity
56
	 * @param string $userId
57
	 * @param Folder $rootFolder
58
	 * @param int|null $size Desired (max) image size, null to use the default.
59
	 *                       Special value DO_NOT_CROP_OR_SCALE can be used to opt out of
60
	 *                       scaling and cropping altogether.
61
	 * @return array|null Image data in format accepted by \OCA\Music\Http\FileResponse
62
	 */
63
	public function getCover($entity, string $userId, Folder $rootFolder, int $size=null) {
64
		// Skip using cache in case the cover is requested in specific size
65
		if ($size !== null) {
66
			return $this->readCover($entity, $rootFolder, $size);
67
		} else {
68
			$dataAndHash = $this->getCoverAndHash($entity, $userId, $rootFolder);
0 ignored issues
show
Bug introduced by
It seems like $entity can also be of type OCA\Music\Db\PodcastChannel; however, parameter $entity of OCA\Music\Utility\CoverHelper::getCoverAndHash() does only seem to accept OCA\Music\Db\Album|OCA\Music\Db\Artist, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

68
			$dataAndHash = $this->getCoverAndHash(/** @scrutinizer ignore-type */ $entity, $userId, $rootFolder);
Loading history...
69
			return $dataAndHash['data'];
70
		}
71
	}
72
73
	/**
74
	 * Get cover image of an album or and artist along with the image's hash
75
	 *
76
	 * The hash is non-null only in case the cover is/was cached.
77
	 *
78
	 * @param Album|Artist $entity
79
	 * @param string $userId
80
	 * @param Folder $rootFolder
81
	 * @return array Dictionary with keys 'data' and 'hash'
82
	 */
83
	public function getCoverAndHash($entity, string $userId, Folder $rootFolder) : array {
84
		$hash = $this->cache->get($userId, self::getHashKey($entity));
85
		$data = null;
86
87
		if ($hash !== null) {
88
			$data = $this->getCoverFromCache($hash, $userId);
89
		}
90
		if ($data === null) {
91
			$hash = null;
92
			$data = $this->readCover($entity, $rootFolder, $this->coverSize);
93
			if ($data !== null) {
94
				$hash = $this->addCoverToCache($entity, $userId, $data);
95
			}
96
		}
97
98
		return ['data' => $data, 'hash' => $hash];
99
	}
100
101
	/**
102
	 * Get all album cover hashes for one user.
103
	 * @param string $userId
104
	 * @return array with album IDs as keys and hashes as values
105
	 */
106
	public function getAllCachedAlbumCoverHashes(string $userId) : array {
107
		$rows = $this->cache->getAll($userId, 'album_cover_hash_');
108
		$hashes = [];
109
		$prefixLen = \strlen('album_cover_hash_');
110
		foreach ($rows as $row) {
111
			$albumId = \substr($row['key'], $prefixLen);
112
			$hashes[$albumId] = $row['data'];
113
		}
114
		return $hashes;
115
	}
116
117
	/**
118
	 * Get cover image with given hash from the cache
119
	 *
120
	 * @param string $hash
121
	 * @param string $userId
122
	 * @param bool $asBase64
123
	 * @return array|null Image data in format accepted by \OCA\Music\Http\FileResponse
124
	 */
125
	public function getCoverFromCache(string $hash, string $userId, bool $asBase64 = false) {
126
		$cached = $this->cache->get($userId, 'cover_' . $hash);
127
		if ($cached !== null) {
128
			$delimPos = \strpos($cached, '|');
129
			$mime = \substr($cached, 0, $delimPos);
130
			$content = \substr($cached, $delimPos + 1);
131
			if (!$asBase64) {
132
				$content = \base64_decode($content);
133
			}
134
			return ['mimetype' => $mime, 'content' => $content];
135
		}
136
		return null;
137
	}
138
139
	/**
140
	 * Cache the given cover image data
141
	 * @param Album|Artist $entity
142
	 * @param string $userId
143
	 * @param array $coverData
144
	 * @return string|null Hash of the cached cover
145
	 */
146
	private function addCoverToCache($entity, string $userId, array $coverData) {
147
		$mime = $coverData['mimetype'];
148
		$content = $coverData['content'];
149
		$hash = null;
150
		$hashKey = self::getHashKey($entity);
151
152
		if ($mime && $content) {
153
			$size = \strlen($content);
154
			if ($size < self::MAX_SIZE_TO_CACHE) {
155
				$hash = \hash('md5', $content);
156
				// cache the data with hash as a key
157
				try {
158
					$this->cache->add($userId, 'cover_' . $hash, $mime . '|' . \base64_encode($content));
159
				} catch (UniqueConstraintViolationException $ex) {
160
					$this->logger->log("Cover with hash $hash is already cached", 'debug');
161
				}
162
				// cache the hash with hashKey as a key
163
				try {
164
					$this->cache->add($userId, $hashKey, $hash);
165
				} catch (UniqueConstraintViolationException $ex) {
166
					$this->logger->log("Cover hash with key $hashKey is already cached", 'debug');
167
				}
168
				// collection.json needs to be regenrated the next time it's fetched
169
				$this->cache->remove($userId, 'collection');
170
			} else {
171
				$this->logger->log("Cover image of entity with key $hashKey is large ($size B), skip caching", 'debug');
172
			}
173
		}
174
175
		return $hash;
176
	}
177
178
	/**
179
	 * Remove album cover image from cache if it is there. Silently do nothing if there
180
	 * is no cached cover. All users are targeted if no $userId passed.
181
	 */
182
	public function removeAlbumCoverFromCache(int $albumId, string $userId=null) {
183
		$this->cache->remove($userId, 'album_cover_hash_' . $albumId);
184
	}
185
186
	/**
187
	 * Remove artist cover image from cache if it is there. Silently do nothing if there
188
	 * is no cached cover. All users are targeted if no $userId passed.
189
	 */
190
	public function removeArtistCoverFromCache(int $artistId, string $userId=null) {
191
		$this->cache->remove($userId, 'artist_cover_hash_' . $artistId);
192
	}
193
194
	/**
195
	 * Read cover image from the entity-specific file or URL and scale it unless the caller opts out of it
196
	 * @param Album|Artist|PodcastChannel $entity
197
	 * @param Folder $rootFolder
198
	 * @param int $size Maximum size for the image to read, larger images are scaled down.
199
	 *                  Special value DO_NOT_CROP_OR_SCALE can be used to opt out of
200
	 *                  scaling and cropping altogether.
201
	 * @return array|null Image data in format accepted by \OCA\Music\Http\FileResponse
202
	 */
203
	private function readCover($entity, Folder $rootFolder, int $size) : ?array {
204
		if ($entity instanceof PodcastChannel) {
205
			$response = ['mimetype' => null, 'content' => \file_get_contents($entity->getImageUrl())];
206
		} else {
207
			$response = $this->readCoverFromLocalFile($entity, $rootFolder);
208
		}
209
210
		if ($response !== null) {
211
			if ($size !== self::DO_NOT_CROP_OR_SCALE) {
212
				$response = $this->scaleDownAndCrop($response, $size);
213
			} elseif ($response['mimetype'] === null) {
214
				$response['mimetype'] = self::autoDetectMime($response['content']);
215
			}
216
		}
217
218
		return $response;
219
	}
220
221
	/**
222
	 * Read cover image from the file system
223
	 * @param Album|Artist $entity
224
	 * @param Folder $rootFolder
225
	 * @return array|null Image data in format accepted by \OCA\Music\Http\FileResponse
226
	 */
227
	private function readCoverFromLocalFile($entity, Folder $rootFolder) {
228
		$response = null;
229
230
		$coverId = $entity->getCoverFileId();
231
		if ($coverId > 0) {
232
			$node = $rootFolder->getById($coverId)[0] ?? null;
233
			if ($node instanceof File) {
234
				$mime = $node->getMimeType();
235
				
236
				if (\strpos($mime, 'audio') === 0) { // embedded cover image
237
					$cover = $this->extractor->parseEmbeddedCoverArt($node); // TODO: currently only album cover supported
238
239
					if ($cover !== null) {
240
						$response = ['mimetype' => $cover['image_mime'], 'content' => $cover['data']];
241
					}
242
				} else { // separate image file
243
					$response = ['mimetype' => $mime, 'content' => $node->getContent()];
244
				}
245
			}
246
247
			if ($response === null) {
248
				$class = \get_class($entity);
249
				$this->logger->log("Requested cover not found for $class entity {$entity->getId()}, coverId=$coverId", 'error');
250
			}
251
		}
252
253
		return $response;
254
	}
255
256
	/**
257
	 * Scale down images to reduce size and crop to square shape
258
	 *
259
	 * If one of the dimensions of the image is smaller than the maximum, then just
260
	 * crop to square shape but do not scale.
261
	 * @param array $image The image to be scaled down in format accepted by \OCA\Music\Http\FileResponse
262
	 * @param integer $maxSize The maximum size in pixels for the square shaped output
263
	 * @return array The processed image in format accepted by \OCA\Music\Http\FileResponse
264
	 */
265
	public function scaleDownAndCrop(array $image, int $maxSize) : array {
266
		$meta = \getimagesizefromstring($image['content']);
267
		$srcWidth = $meta[0];
268
		$srcHeight = $meta[1];
269
270
		// only process picture if it's larger than target size or not perfect square
271
		if ($srcWidth > $maxSize || $srcHeight > $maxSize || $srcWidth != $srcHeight) {
272
			$img = imagecreatefromstring($image['content']);
273
274
			if ($img === false) {
275
				$this->logger->log('Failed to open cover image for downscaling', 'warn');
276
			} else {
277
				$srcCropSize = \min($srcWidth, $srcHeight);
278
				$srcX = (int)(($srcWidth - $srcCropSize) / 2);
279
				$srcY = (int)(($srcHeight - $srcCropSize) / 2);
280
281
				$dstSize = \min($maxSize, $srcCropSize);
282
				$scaledImg = \imagecreatetruecolor($dstSize, $dstSize);
283
284
				if ($scaledImg === false) {
285
					$this->logger->log("Failed to create scaled image of size $dstSize x $dstSize", 'warn');
286
					\imagedestroy($img);
287
				} else {
288
					\imagecopyresampled($scaledImg, $img, 0, 0, $srcX, $srcY, $dstSize, $dstSize, $srcCropSize, $srcCropSize);
289
					\imagedestroy($img);
290
291
					\ob_start();
292
					\ob_clean();
293
					$image['mimetype'] = $meta['mime']; // override the supplied mime with the auto-detected one
294
					switch ($image['mimetype']) {
295
						case 'image/jpeg':
296
							imagejpeg($scaledImg, null, 75);
297
							$image['content'] = \ob_get_contents();
298
							break;
299
						case 'image/png':
300
							imagepng($scaledImg, null, 7, PNG_ALL_FILTERS);
301
							$image['content'] = \ob_get_contents();
302
							break;
303
						case 'image/gif':
304
							imagegif($scaledImg, null);
305
							$image['content'] = \ob_get_contents();
306
							break;
307
						default:
308
							$this->logger->log("Cover image type {$image['mimetype']} not supported for downscaling", 'warn');
309
							break;
310
					}
311
					\ob_end_clean();
312
					\imagedestroy($scaledImg);
313
				}
314
			}
315
		}
316
		return $image;
317
	}
318
319
	private static function autoDetectMime($imageContent) {
320
		return \getimagesizefromstring($imageContent)['mime'];
321
	}
322
323
	/**
324
	 * @param Album|Artist|PodcastChannel $entity
325
	 * @throws \InvalidArgumentException if entity is not one of the expected types
326
	 * @return string
327
	 */
328
	private static function getHashKey($entity) {
329
		if ($entity instanceof Album) {
330
			return 'album_cover_hash_' . $entity->getId();
331
		} elseif ($entity instanceof Artist) {
332
			return 'artist_cover_hash_' . $entity->getId();
333
		} elseif ($entity instanceof PodcastChannel) {
0 ignored issues
show
introduced by
$entity is always a sub-type of OCA\Music\Db\PodcastChannel.
Loading history...
334
			return 'podcast_cover_hash' . $entity->getId();
335
		} else {
336
			throw new \InvalidArgumentException('Unexpected entity type');
337
		}
338
	}
339
340
	/**
341
	 * Create and store an access token which can be used to read cover images of a user.
342
	 * A user may have only one valid cover image access token at a time; the latest token
343
	 * always overwrites the previously obtained one.
344
	 * 
345
	 * The reason this is needed is because the mediaSession in Firefox loads the cover images
346
	 * in a context where normal cookies and other standard request headers are not available. 
347
	 * Hence, we need to provide the cover images as "public" resources, i.e. without requiring 
348
	 * that the caller is logged in to the cloud. But still, we don't want to let just anyone 
349
	 * load the user data. The solution is to use a temporary token which grants access just to
350
	 * the cover images. This token can be then sent as URL argument by the mediaSession.
351
	 */
352
	public function createAccessToken(string $userId) : string {
353
		$token = Random::secure(32);
354
		// It might be neater to use a dedicated DB table for this, but the generic cache table
355
		// will do, at least for now.
356
		$this->cache->set($userId, 'cover_access_token', $token);
357
		return $token;
358
	}
359
360
	/**
361
	 * @see CoverHelper::createAccessToken
362
	 * @throws \OutOfBoundsException if the token is not valid
363
	 */
364
	public function getUserForAccessToken(?string $token) : string {
365
		if ($token === null) {
366
			throw new \OutOfBoundsException('Cannot get user for a null token');
367
		}
368
		$userId = $this->cache->getOwner('cover_access_token', $token);
369
		if ($userId === null) {
370
			throw new \OutOfBoundsException('No userId found for the given token');
371
		}
372
		return $userId;
373
	}
374
}
375