Passed
Push — master ( 9f6f8b...b68386 )
by Pauli
03:11 queued 16s
created

CoverHelper   A

Complexity

Total Complexity 39

Size/Duplication

Total Lines 281
Duplicated Lines 0 %

Test Coverage

Coverage 0%

Importance

Changes 5
Bugs 0 Features 0
Metric Value
eloc 125
c 5
b 0
f 0
dl 0
loc 281
ccs 0
cts 123
cp 0
rs 9.28
wmc 39

11 Methods

Rating   Name   Duplication   Size   Complexity  
A getCoverFromCache() 0 12 3
B readCover() 0 33 7
B addCoverToCache() 0 30 6
A removeAlbumCoverFromCache() 0 2 1
B scaleDownAndCrop() 0 47 8
A getCover() 0 7 2
A getHashKey() 0 7 3
A getAllCachedAlbumCoverHashes() 0 8 2
A getCoverAndHash() 0 16 4
A __construct() 0 11 2
A removeArtistCoverFromCache() 0 2 1
1
<?php
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 - 2020
11
 */
12
13
namespace OCA\Music\Utility;
14
15
use \OCA\Music\AppFramework\Core\Logger;
16
use \OCA\Music\Db\Album;
17
use \OCA\Music\Db\Artist;
18
use \OCA\Music\Db\Cache;
19
20
use \OCP\Files\Folder;
0 ignored issues
show
Bug introduced by
The type OCP\Files\Folder was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
21
use \OCP\Files\File;
0 ignored issues
show
Bug introduced by
The type OCP\Files\File was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
22
23
use \OCP\IConfig;
0 ignored issues
show
Bug introduced by
The type OCP\IConfig was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
24
25
use \Doctrine\DBAL\Exception\UniqueConstraintViolationException;
0 ignored issues
show
Bug introduced by
The type Doctrine\DBAL\Exception\...raintViolationException was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
26
use Doctrine\Instantiator\Exception\InvalidArgumentException;
27
28
/**
29
 * utility to get cover image for album
30
 */
31
class CoverHelper {
32
	private $extractor;
33
	private $cache;
34
	private $coverSize;
35
	private $logger;
36
37
	const MAX_SIZE_TO_CACHE = 102400;
38
	const DO_NOT_CROP_OR_SCALE = -1;
39
40
	public function __construct(
41
			Extractor $extractor,
42
			Cache $cache,
43
			IConfig $config,
44
			Logger $logger) {
45
		$this->extractor = $extractor;
46
		$this->cache = $cache;
47
		$this->logger = $logger;
48
49
		// Read the cover size to use from config.php or use the default
50
		$this->coverSize = intval($config->getSystemValue('music.cover_size')) ?: 380;
51
	}
52
53
	/**
54
	 * Get cover image of an album or and artist
55
	 *
56
	 * @param Album|Artist $entity
57
	 * @param string $userId
58
	 * @param Folder $rootFolder
59
	 * @param int|null $size Desired (max) image size, null to use the default.
60
	 *                       Special value DO_NOT_CROP_OR_SCALE can be used to opt out of
61
	 *                       scaling and cropping altogether.
62
	 * @return array|null Image data in format accepted by \OCA\Music\Http\FileResponse
63
	 */
64
	public function getCover($entity, $userId, $rootFolder, $size=null) {
65
		// Skip using cache in case the cover is requested in specific size
66
		if ($size) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $size of type integer|null is loosely compared to true; this is ambiguous if the integer can be 0. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
67
			return $this->readCover($entity, $rootFolder, $size);
68
		} else {
69
			$dataAndHash = $this->getCoverAndHash($entity, $userId, $rootFolder);
70
			return $dataAndHash['data'];
71
		}
72
	}
73
74
	/**
75
	 * Get cover image of an album or and artist along with the image's hash
76
	 * 
77
	 * The hash is non-null only in case the cover is/was cached.
78
	 *
79
	 * @param Album|Artist $entity
80
	 * @param string $userId
81
	 * @param Folder $rootFolder
82
	 * @return array Dictionary with keys 'data' and 'hash'
83
	 */
84
	public function getCoverAndHash($entity, $userId, $rootFolder) {
85
		$hash = $this->cache->get($userId, self::getHashKey($entity));
86
		$data = null;
87
88
		if ($hash !== null) {
89
			$data = $this->getCoverFromCache($hash, $userId);
90
		}
91
		if ($data === null) {
92
			$hash = null;
93
			$data = $this->readCover($entity, $rootFolder, $this->coverSize);
94
			if ($data !== null) {
95
				$hash = $this->addCoverToCache($entity, $userId, $data);
96
			}
97
		}
98
99
		return ['data' => $data, 'hash' => $hash];
100
	}
101
102
	/**
103
	 * Get all album cover hashes for one user.
104
	 * @param string $userId
105
	 * @return array with album IDs as keys and hashes as values
106
	 */
107
	public function getAllCachedAlbumCoverHashes($userId) {
108
		$rows = $this->cache->getAll($userId, 'album_cover_hash_');
109
		$hashes = [];
110
		foreach ($rows as $row) {
111
			$albumId = \explode('_', $row['key'])[1];
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($hash, $userId, $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 Entity $entity Album or Artist
0 ignored issues
show
Bug introduced by
The type OCA\Music\Utility\Entity was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
142
	 * @param string $userId
143
	 * @param array $coverData
144
	 * @return string|null Hash of the cached cover
145
	 */
146
	private function addCoverToCache($entity, $userId, $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.
181
	 * @param int $albumId
182
	 * @param string $userId
183
	 */
184
	public function removeAlbumCoverFromCache($albumId, $userId) {
185
		$this->cache->remove($userId, 'album_cover_hash_' . $albumId);
186
	}
187
188
	/**
189
	 * Remove artist cover image from cache if it is there. Silently do nothing if there
190
	 * is no cached cover.
191
	 * @param int $artistId
192
	 * @param string $userId
193
	 */
194
	public function removeArtistCoverFromCache($artistId, $userId) {
195
		$this->cache->remove($userId, 'artist_cover_hash_' . $artistId);
196
	}
197
198
	/**
199
	 * Read cover image from the file system
200
	 * @param Enity $entity Album or Artist entity
0 ignored issues
show
Bug introduced by
The type OCA\Music\Utility\Enity was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
201
	 * @param Folder $rootFolder
202
	 * @param int $size Maximum size for the image to read, larger images are scaled down.
203
	 *                  Special value DO_NOT_CROP_OR_SCALE can be used to opt out of
204
	 *                  scaling and cropping altogether.
205
	 * @return array|null Image data in format accepted by \OCA\Music\Http\FileResponse
206
	 */
207
	private function readCover($entity, $rootFolder, $size) {
208
		$response = null;
209
		$coverId = $entity->getCoverFileId();
210
211
		if ($coverId > 0) {
212
			$nodes = $rootFolder->getById($coverId);
213
			if (\count($nodes) > 0) {
214
				// get the first valid node (there shouldn't be more than one node anyway)
215
				/* @var $node File */
216
				$node = $nodes[0];
217
				$mime = $node->getMimeType();
218
219
				if (\strpos($mime, 'audio') === 0) { // embedded cover image
220
					$cover = $this->extractor->parseEmbeddedCoverArt($node); // TODO: currently only album cover supported
221
222
					if ($cover !== null) {
0 ignored issues
show
introduced by
The condition $cover !== null is always true.
Loading history...
223
						$response = ['mimetype' => $cover['image_mime'], 'content' => $cover['data']];
224
					}
225
				} else { // separate image file
226
					$response = ['mimetype' => $mime, 'content' => $node->getContent()];
227
				}
228
			}
229
230
			if ($response === null) {
231
				$class = \get_class($entity);
232
				$this->logger->log("Requested cover not found for $class entity {$entity->getId()}, coverId=$coverId", 'error');
233
			}
234
			else if ($size !== self::DO_NOT_CROP_OR_SCALE) {
235
				$response['content'] = $this->scaleDownAndCrop($response['content'], $size);
236
			}
237
		}
238
239
		return $response;
240
	}
241
242
	/**
243
	 * Scale down images to reduce size and crop to square shape
244
	 *
245
	 * If one of the dimensions of the image is smaller than the maximum, then just
246
	 * crop to square shape but do not scale.
247
	 * @param string $image The image to be scaled down as string
248
	 * @param integer $maxSize The maximum size in pixels for the square shaped output
249
	 * @return string The processed image as string
250
	 */
251
	public function scaleDownAndCrop($image, $maxSize) {
252
		$meta = \getimagesizefromstring($image);
253
		$srcWidth = $meta[0];
254
		$srcHeight = $meta[1];
255
256
		// only process picture if it's larger than target size or not perfect square
257
		if ($srcWidth > $maxSize || $srcHeight > $maxSize || $srcWidth != $srcHeight) {
258
			$img = imagecreatefromstring($image);
259
260
			if ($img === false) {
261
				$this->logger->log('Failed to open cover image for downscaling', 'warning');
262
			}
263
			else {
264
				$srcCropSize = \min($srcWidth, $srcHeight);
265
				$srcX = ($srcWidth - $srcCropSize) / 2;
266
				$srcY = ($srcHeight - $srcCropSize) / 2;
267
268
				$dstSize = \min($maxSize, $srcCropSize);
269
				$scaledImg = \imagecreatetruecolor($dstSize, $dstSize);
270
				\imagecopyresampled($scaledImg, $img, 0, 0, $srcX, $srcY, $dstSize, $dstSize, $srcCropSize, $srcCropSize);
0 ignored issues
show
Bug introduced by
It seems like $scaledImg can also be of type false; however, parameter $dst_image of imagecopyresampled() does only seem to accept resource, 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

270
				\imagecopyresampled(/** @scrutinizer ignore-type */ $scaledImg, $img, 0, 0, $srcX, $srcY, $dstSize, $dstSize, $srcCropSize, $srcCropSize);
Loading history...
271
				\imagedestroy($img);
272
273
				\ob_start();
274
				\ob_clean();
275
				$mime = $meta['mime'];
276
				switch ($mime) {
277
					case 'image/jpeg':
278
						imagejpeg($scaledImg, null, 75);
0 ignored issues
show
Bug introduced by
It seems like $scaledImg can also be of type false; however, parameter $image of imagejpeg() does only seem to accept resource, 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

278
						imagejpeg(/** @scrutinizer ignore-type */ $scaledImg, null, 75);
Loading history...
279
						$image = \ob_get_contents();
280
						break;
281
					case 'image/png':
282
						imagepng($scaledImg, null, 7, PNG_ALL_FILTERS);
0 ignored issues
show
Bug introduced by
It seems like $scaledImg can also be of type false; however, parameter $image of imagepng() does only seem to accept resource, 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

282
						imagepng(/** @scrutinizer ignore-type */ $scaledImg, null, 7, PNG_ALL_FILTERS);
Loading history...
283
						$image = \ob_get_contents();
284
						break;
285
					case 'image/gif':
286
						imagegif($scaledImg, null);
0 ignored issues
show
Bug introduced by
It seems like $scaledImg can also be of type false; however, parameter $image of imagegif() does only seem to accept resource, 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

286
						imagegif(/** @scrutinizer ignore-type */ $scaledImg, null);
Loading history...
287
						$image = \ob_get_contents();
288
						break;
289
					default:
290
						$this->logger->log("Cover image type $mime not supported for downscaling", 'warning');
291
						break;
292
				}
293
				\ob_end_clean();
294
				\imagedestroy($scaledImg);
0 ignored issues
show
Bug introduced by
It seems like $scaledImg can also be of type false; however, parameter $image of imagedestroy() does only seem to accept resource, 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

294
				\imagedestroy(/** @scrutinizer ignore-type */ $scaledImg);
Loading history...
295
			}
296
		}
297
		return $image;
298
	}
299
300
	/**
301
	 * @param Entity $entity An Album or Artist entity
302
	 * @throws InvalidArgumentException if entity is not one of the expected types
303
	 * @return string
304
	 */
305
	private static function getHashKey($entity) {
306
		if ($entity instanceof Album) {
307
			return 'album_cover_hash_' . $entity->getId();
308
		} elseif ($entity instanceof Artist) {
309
			return 'artist_cover_hash_' . $entity->getId();
310
		} else {
311
			throw new \InvalidArgumentException('Unexpected entity type');
312
		}
313
	}
314
}
315