Passed
Push — master ( de702b...3e9331 )
by Pauli
02:18
created

Scanner::__construct()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 29
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 15
nc 2
nop 12
dl 0
loc 29
ccs 0
cts 16
cp 0
crap 6
rs 9.7666
c 1
b 0
f 0

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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 Morris Jobke <[email protected]>
10
 * @author Pauli Järvinen <[email protected]>
11
 * @copyright Morris Jobke 2013, 2014
12
 * @copyright Pauli Järvinen 2016 - 2020
13
 */
14
15
namespace OCA\Music\Utility;
16
17
use OC\Hooks\PublicEmitter;
0 ignored issues
show
Bug introduced by
The type OC\Hooks\PublicEmitter 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...
18
19
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...
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\IRootFolder;
0 ignored issues
show
Bug introduced by
The type OCP\Files\IRootFolder 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 \OCA\Music\AppFramework\Core\Logger;
24
use \OCA\Music\BusinessLayer\ArtistBusinessLayer;
25
use \OCA\Music\BusinessLayer\AlbumBusinessLayer;
26
use \OCA\Music\BusinessLayer\GenreBusinessLayer;
27
use \OCA\Music\BusinessLayer\PlaylistBusinessLayer;
28
use \OCA\Music\BusinessLayer\TrackBusinessLayer;
29
use \OCA\Music\Db\Cache;
30
use \OCA\Music\Db\Maintenance;
31
32
use Symfony\Component\Console\Output\OutputInterface;
33
34
class Scanner extends PublicEmitter {
35
	private $extractor;
36
	private $artistBusinessLayer;
37
	private $albumBusinessLayer;
38
	private $trackBusinessLayer;
39
	private $playlistBusinessLayer;
40
	private $genreBusinessLayer;
41
	private $cache;
42
	private $coverHelper;
43
	private $logger;
44
	private $maintenance;
45
	private $userMusicFolder;
46
	private $rootFolder;
47
48
	public function __construct(Extractor $extractor,
49
								ArtistBusinessLayer $artistBusinessLayer,
50
								AlbumBusinessLayer $albumBusinessLayer,
51
								TrackBusinessLayer $trackBusinessLayer,
52
								PlaylistBusinessLayer $playlistBusinessLayer,
53
								GenreBusinessLayer $genreBusinessLayer,
54
								Cache $cache,
55
								CoverHelper $coverHelper,
56
								Logger $logger,
57
								Maintenance $maintenance,
58
								UserMusicFolder $userMusicFolder,
59
								IRootFolder $rootFolder) {
60
		$this->extractor = $extractor;
61
		$this->artistBusinessLayer = $artistBusinessLayer;
62
		$this->albumBusinessLayer = $albumBusinessLayer;
63
		$this->trackBusinessLayer = $trackBusinessLayer;
64
		$this->playlistBusinessLayer = $playlistBusinessLayer;
65
		$this->genreBusinessLayer = $genreBusinessLayer;
66
		$this->cache = $cache;
67
		$this->coverHelper = $coverHelper;
68
		$this->logger = $logger;
69
		$this->maintenance = $maintenance;
70
		$this->userMusicFolder = $userMusicFolder;
71
		$this->rootFolder = $rootFolder;
72
73
		// Trying to enable stream support
74
		if (\ini_get('allow_url_fopen') !== '1') {
75
			$this->logger->log('allow_url_fopen is disabled. It is strongly advised to enable it in your php.ini', 'warn');
76
			@\ini_set('allow_url_fopen', '1');
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for ini_set(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

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

76
			/** @scrutinizer ignore-unhandled */ @\ini_set('allow_url_fopen', '1');

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
77
		}
78
	}
79
80
	/**
81
	 * Gets called by 'post_write' (file creation, file update) and 'post_share' hooks
82
	 * @param \OCP\Files\File $file the file
83
	 * @param string $userId
84
	 * @param \OCP\Files\Folder $userHome
85
	 * @param string|null $filePath Deduced from $file if not given
86
	 */
87
	public function update($file, $userId, $userHome, $filePath = null) {
88
		if ($filePath === null) {
89
			$filePath = $file->getPath();
90
		}
91
92
		// debug logging
93
		$this->logger->log("update - $filePath", 'debug');
94
95
		if (!($file instanceof File) || !$userId || !($userHome instanceof Folder)) {
96
			$this->logger->log('Invalid arguments given to Scanner.update - file='.\get_class($file).
97
					", userId=$userId, userHome=".\get_class($userHome), 'warn');
98
			return;
99
		}
100
101
		// skip files that aren't inside the user specified path
102
		if (!$this->pathIsUnderMusicFolder($filePath, $userId)) {
103
			$this->logger->log("skipped - file is outside of specified music folder", 'debug');
104
			return;
105
		}
106
107
		$mimetype = $file->getMimeType();
108
109
		// debug logging
110
		$this->logger->log("update - mimetype $mimetype", 'debug');
111
112
		if (Util::startsWith($mimetype, 'image')) {
113
			$this->updateImage($file, $userId);
114
		} elseif (Util::startsWith($mimetype, 'audio') && !self::isPlaylistMime($mimetype)) {
115
			$this->updateAudio($file, $userId, $userHome, $filePath, $mimetype);
116
		}
117
	}
118
119
	private static function isPlaylistMime($mime) {
120
		return $mime == 'audio/mpegurl' || $mime == 'audio/x-scpls';
121
	}
122
123
	private function pathIsUnderMusicFolder($filePath, $userId) {
124
		$musicFolder = $this->userMusicFolder->getFolder($userId);
125
		$musicPath = $musicFolder->getPath();
126
		return Util::startsWith($filePath, $musicPath);
127
	}
128
129
	private function updateImage($file, $userId) {
130
		$coverFileId = $file->getId();
131
		$parentFolderId = $file->getParent()->getId();
132
		if ($this->albumBusinessLayer->updateFolderCover($coverFileId, $parentFolderId)) {
133
			$this->logger->log('updateImage - the image was set as cover for some album(s)', 'debug');
134
			$this->cache->remove($userId, 'collection');
135
		}
136
		if ($artistId = $this->artistBusinessLayer->updateCover($file, $userId)) {
137
			$this->logger->log("updateImage - the image was set as cover for the artist $artistId", 'debug');
138
			$this->coverHelper->removeArtistCoverFromCache($artistId, $userId);
0 ignored issues
show
Bug introduced by
$artistId of type OCA\Music\BusinessLayer\artistId is incompatible with the type integer expected by parameter $artistId of OCA\Music\Utility\CoverH...eArtistCoverFromCache(). ( Ignorable by Annotation )

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

138
			$this->coverHelper->removeArtistCoverFromCache(/** @scrutinizer ignore-type */ $artistId, $userId);
Loading history...
139
		}
140
	}
141
142
	private function updateAudio($file, $userId, $userHome, $filePath, $mimetype) {
143
		if (\ini_get('allow_url_fopen')) {
144
			$this->emit('\OCA\Music\Utility\Scanner', 'update', [$filePath]);
145
146
			$meta = $this->extractMetadata($file, $userHome, $filePath);
147
			$fileId = $file->getId();
148
149
			// add/update artist and get artist entity
150
			$artist = $this->artistBusinessLayer->addOrUpdateArtist($meta['artist'], $userId);
151
			$artistId = $artist->getId();
152
153
			// add/update albumArtist and get artist entity
154
			$albumArtist = $this->artistBusinessLayer->addOrUpdateArtist($meta['albumArtist'], $userId);
155
			$albumArtistId = $albumArtist->getId();
156
157
			// add/update album and get album entity
158
			$album = $this->albumBusinessLayer->addOrUpdateAlbum(
159
					$meta['album'], $albumArtistId, $userId);
160
			$albumId = $album->getId();
161
162
			// add/update genre and get genre entity
163
			$genre = $this->genreBusinessLayer->addOrUpdateGenre($meta['genre'], $userId);
164
165
			// add/update track and get track entity
166
			$track = $this->trackBusinessLayer->addOrUpdateTrack(
167
					$meta['title'], $meta['trackNumber'],  $meta['discNumber'], $meta['year'], $genre->getId(),
168
					$artistId, $albumId, $fileId, $mimetype, $userId, $meta['length'], $meta['bitrate']);
169
170
			// if present, use the embedded album art as cover for the respective album
171
			if ($meta['picture'] != null) {
172
				$this->albumBusinessLayer->setCover($fileId, $albumId);
173
				$this->coverHelper->removeAlbumCoverFromCache($albumId, $userId);
174
			}
175
			// if this file is an existing file which previously was used as cover for an album but now
176
			// the file no longer contains any embedded album art
177
			elseif ($this->albumBusinessLayer->albumCoverIsOneOfFiles($albumId, [$fileId])) {
178
				$this->albumBusinessLayer->removeCovers([$fileId]);
179
				$this->findEmbeddedCoverForAlbum($albumId, $userId, $userHome);
180
				$this->coverHelper->removeAlbumCoverFromCache($albumId, $userId);
181
			}
182
183
			// invalidate the cache as the music collection was changed
184
			$this->cache->remove($userId, 'collection');
185
		
186
			// debug logging
187
			$this->logger->log('imported entities - ' .
188
					"artist: $artistId, albumArtist: $albumArtistId, album: $albumId, track: {$track->getId()}",
189
					'debug');
190
		}
191
	}
192
193
	private function extractMetadata($file, $userHome, $filePath) {
194
		$fieldsFromFileName = self::parseFileName($file->getName());
195
		$fileInfo = $this->extractor->extract($file);
196
		$meta = [];
197
198
		// Track artist and album artist
199
		$meta['artist'] = ExtractorGetID3::getTag($fileInfo, 'artist');
200
		$meta['albumArtist'] = ExtractorGetID3::getFirstOfTags($fileInfo, ['band', 'albumartist', 'album artist', 'album_artist']);
201
202
		// use artist and albumArtist as fallbacks for each other
203
		if (self::isNullOrEmpty($meta['albumArtist'])) {
204
			$meta['albumArtist'] = $meta['artist'];
205
		}
206
207
		if (self::isNullOrEmpty($meta['artist'])) {
208
			$meta['artist'] = $meta['albumArtist'];
209
		}
210
211
		// set 'Unknown Artist' in case neither artist nor albumArtist was found
212
		if (self::isNullOrEmpty($meta['artist'])) {
213
			$meta['artist'] = null;
214
			$meta['albumArtist'] = null;
215
		}
216
217
		// title
218
		$meta['title'] = ExtractorGetID3::getTag($fileInfo, 'title');
219
		if (self::isNullOrEmpty($meta['title'])) {
220
			$meta['title'] = $fieldsFromFileName['title'];
221
		}
222
223
		// album
224
		$meta['album'] = ExtractorGetID3::getTag($fileInfo, 'album');
225
		if (self::isNullOrEmpty($meta['album'])) {
226
			// album name not set in fileinfo, use parent folder name as album name unless it is the root folder
227
			$dirPath = \dirname($filePath);
228
			if ($userHome->getPath() === $dirPath) {
229
				$meta['album'] = null;
230
			} else {
231
				$meta['album'] = \basename($dirPath);
232
			}
233
		}
234
235
		// track number
236
		$meta['trackNumber'] = ExtractorGetID3::getFirstOfTags($fileInfo, ['track_number', 'tracknumber', 'track'],
237
				$fieldsFromFileName['track_number']);
238
		$meta['trackNumber'] = self::normalizeOrdinal($meta['trackNumber']);
239
240
		// disc number
241
		$meta['discNumber'] = ExtractorGetID3::getFirstOfTags($fileInfo, ['disc_number', 'discnumber', 'part_of_a_set'], '1');
242
		$meta['discNumber'] = self::normalizeOrdinal($meta['discNumber']);
243
244
		// year
245
		$meta['year'] = ExtractorGetID3::getFirstOfTags($fileInfo, ['year', 'date', 'creation_date']);
246
		$meta['year'] = self::normalizeYear($meta['year']);
247
248
		$meta['genre'] = ExtractorGetID3::getTag($fileInfo, 'genre') ?: ''; // empty string used for "scanned but unknown"
249
250
		$meta['picture'] = ExtractorGetID3::getTag($fileInfo, 'picture', true);
251
252
		if (\array_key_exists('playtime_seconds', $fileInfo)) {
253
			$meta['length'] = \ceil($fileInfo['playtime_seconds']);
254
		} else {
255
			$meta['length'] = null;
256
		}
257
258
		if (\array_key_exists('audio', $fileInfo) && \array_key_exists('bitrate', $fileInfo['audio'])) {
259
			$meta['bitrate'] = $fileInfo['audio']['bitrate'];
260
		} else {
261
			$meta['bitrate'] = null;
262
		}
263
264
		return $meta;
265
	}
266
267
	/**
268
	 * @param string[] $affectedUsers
269
	 * @param int[] $affectedAlbums
270
	 */
271
	private function invalidateCacheOnDelete($affectedUsers, $affectedAlbums, $affectedArtists) {
272
		// Delete may be for one file or for a folder containing thousands of albums.
273
		// If loads of albums got affected, then ditch the whole cache of the affected
274
		// users because removing the cached covers one-by-one could delay the delete
275
		// operation significantly.
276
		$albumCount = \count($affectedAlbums);
277
		$artistCount = \count($affectedArtists);
278
		$userCount = \count($affectedUsers);
279
280
		if ($albumCount + $artistCount > 100) {
281
			$this->logger->log("Delete operation affected $albumCount albums and $artistCount artists. " .
282
								"Invalidate the whole cache of all affected users ($userCount).", 'debug');
283
			foreach ($affectedUsers as $user) {
284
				$this->cache->remove($user);
285
			}
286
		}
287
		else {
288
			// remove the cached covers
289
			if ($artistCount > 0) {
290
				$this->logger->log("Remove covers of $artistCount artist(s) from the cache (if present)", 'debug');
291
				foreach ($affectedArtists as $artistId) {
292
					$this->coverHelper->removeArtistCoverFromCache($artistId, null);
293
				}
294
			}
295
296
			if ($albumCount > 0) {
297
				$this->logger->log("Remove covers of $albumCount album(s) from the cache (if present)", 'debug');
298
				foreach ($affectedAlbums as $albumId) {
299
					$this->coverHelper->removeAlbumCoverFromCache($albumId, null);
300
				}
301
				// remove the cached collection if any album covers were removed
302
				foreach ($affectedUsers as $user) {
303
					$this->cache->remove($user, 'collection');
304
				}
305
			}
306
		}
307
	}
308
309
	/**
310
	 * @param int[] $fileIds
311
	 * @param string[]|null $userIds
312
	 * @return boolean true if anything was removed
313
	 */
314
	private function deleteAudio($fileIds, $userIds=null) {
315
		$this->logger->log('deleteAudio - '. \implode(', ', $fileIds), 'debug');
316
		$this->emit('\OCA\Music\Utility\Scanner', 'delete', [$fileIds, $userIds]);
317
318
		$result = $this->trackBusinessLayer->deleteTracks($fileIds, $userIds);
319
320
		if ($result) { // one or more tracks were removed
321
			// remove obsolete artists and albums, and track references in playlists
322
			$this->albumBusinessLayer->deleteById($result['obsoleteAlbums']);
323
			$this->artistBusinessLayer->deleteById($result['obsoleteArtists']);
324
			$this->playlistBusinessLayer->removeTracksFromAllLists($result['deletedTracks']);
325
326
			// check if a removed track was used as embedded cover art file for a remaining album
327
			foreach ($result['remainingAlbums'] as $albumId) {
328
				if ($this->albumBusinessLayer->albumCoverIsOneOfFiles($albumId, $fileIds)) {
329
					$this->albumBusinessLayer->setCover(null, $albumId);
330
					$this->findEmbeddedCoverForAlbum($albumId);
331
					$this->coverHelper->removeAlbumCoverFromCache($albumId, null);
332
				}
333
			}
334
335
			$this->invalidateCacheOnDelete(
336
					$result['affectedUsers'], $result['obsoleteAlbums'], $result['obsoleteArtists']);
337
338
			$this->logger->log('removed entities - ' . \json_encode($result), 'debug');
339
		}
340
341
		return $result !== false;
342
	}
343
344
	/**
345
	 * @param int[] $fileIds
346
	 * @param string[]|null $userIds
347
	 * @return boolean true if anything was removed
348
	 */
349
	private function deleteImage($fileIds, $userIds=null) {
350
		$this->logger->log('deleteImage - '. \implode(', ', $fileIds), 'debug');
351
352
		$affectedAlbums = $this->albumBusinessLayer->removeCovers($fileIds, $userIds);
353
		$affectedArtists = $this->artistBusinessLayer->removeCovers($fileIds, $userIds);
354
355
		$affectedUsers = \array_merge(
356
			Util::extractUserIds($affectedAlbums),
357
			Util::extractUserIds($affectedArtists)
358
		);
359
		$affectedUsers = \array_unique($affectedUsers);
360
361
		$this->invalidateCacheOnDelete(
362
				$affectedUsers, Util::extractIds($affectedAlbums), Util::extractIds($affectedArtists));
363
364
		return (\count($affectedAlbums) + \count($affectedArtists) > 0);
365
	}
366
367
	/**
368
	 * Gets called by 'unshare' hook and 'delete' hook
369
	 *
370
	 * @param int $fileId ID of the deleted files
371
	 * @param string[]|null $userIds the IDs of the users to remove the file from; if omitted,
372
	 *                               the file is removed from all users (ie. owner and sharees)
373
	 */
374
	public function delete($fileId, $userIds=null) {
375
		if (!$this->deleteAudio([$fileId], $userIds) && !$this->deleteImage([$fileId], $userIds)) {
376
			$this->logger->log("deleted file $fileId was not an indexed " .
377
					'audio file or a cover image', 'debug');
378
		}
379
	}
380
381
	/**
382
	 * Remove all audio files and cover images in the given folder from the database.
383
	 * This gets called when a folder is deleted or unshared from the user.
384
	 *
385
	 * @param \OCP\Files\Folder $folder
386
	 * @param string[]|null $userIds the IDs of the users to remove the folder from; if omitted,
387
	 *                               the folder is removed from all users (ie. owner and sharees)
388
	 */
389
	public function deleteFolder($folder, $userIds=null) {
390
		$audioFiles = $folder->searchByMime('audio');
391
		if (\count($audioFiles) > 0) {
392
			$this->deleteAudio(Util::extractIds($audioFiles), $userIds);
393
		}
394
395
		// NOTE: When a folder is removed, we don't need to check for any image
396
		// files in the folder. This is because those images could be potentially
397
		// used as covers only on the audio files of the same folder and those
398
		// were already removed above.
399
	}
400
401
	/**
402
	 * search for music files by mimetype inside user specified library path
403
	 * (which defaults to user home dir)
404
	 *
405
	 * @return \OCP\Files\File[]
406
	 */
407
	private function getMusicFiles($userId) {
408
		try {
409
			$folder = $this->userMusicFolder->getFolder($userId);
410
		} catch (\OCP\Files\NotFoundException $e) {
0 ignored issues
show
Bug introduced by
The type OCP\Files\NotFoundException 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...
411
			return [];
412
		}
413
414
		// Search files with mime 'audio/*' but filter out the playlist files
415
		$files = $folder->searchByMime('audio');
416
		return array_filter($files, function ($f) {
417
			return !self::isPlaylistMime($f->getMimeType());
418
		});
419
	}
420
421
	/**
422
	 * search for image files by mimetype inside user specified library path
423
	 * (which defaults to user home dir)
424
	 *
425
	 * @return \OCP\Files\File[]
426
	 */
427
	private function getImageFiles($userId) {
428
		try {
429
			$folder = $this->userMusicFolder->getFolder($userId);
430
		} catch (\OCP\Files\NotFoundException $e) {
431
			return [];
432
		}
433
434
		return $folder->searchByMime('image');
435
	}
436
437
	private function getScannedFileIds($userId) {
438
		return $this->trackBusinessLayer->findAllFileIds($userId);
439
	}
440
441
	public function getAllMusicFileIds($userId) {
442
		$musicFiles = $this->getMusicFiles($userId);
443
		return Util::extractIds($musicFiles);
444
	}
445
446
	public function getUnscannedMusicFileIds($userId) {
447
		$scannedIds = $this->getScannedFileIds($userId);
448
		$allIds = $this->getAllMusicFileIds($userId);
449
		$unscannedIds = Util::arrayDiff($allIds, $scannedIds);
450
451
		$count = \count($unscannedIds);
452
		if ($count) {
453
			$this->logger->log("Found $count unscanned music files for user $userId", 'info');
454
		} else {
455
			$this->logger->log("No unscanned music files for user $userId", 'debug');
456
		}
457
458
		return $unscannedIds;
459
	}
460
461
	public function scanFiles($userId, $userHome, $fileIds, OutputInterface $debugOutput = null) {
462
		$count = \count($fileIds);
463
		$this->logger->log("Scanning $count files of user $userId", 'debug');
464
465
		// back up the execution time limit
466
		$executionTime = \intval(\ini_get('max_execution_time'));
467
		// set execution time limit to unlimited
468
		\set_time_limit(0);
469
470
		$count = 0;
471
		foreach ($fileIds as $fileId) {
472
			$fileNodes = $userHome->getById($fileId);
473
			if (\count($fileNodes) > 0) {
474
				$file = $fileNodes[0];
475
				$memBefore = $debugOutput ? \memory_get_usage(true) : 0;
476
				$this->update($file, $userId, $userHome);
477
				if ($debugOutput) {
478
					$memAfter = \memory_get_usage(true);
479
					$memDelta = $memAfter - $memBefore;
480
					$fmtMemAfter = Util::formatFileSize($memAfter);
481
					$fmtMemDelta = Util::formatFileSize($memDelta);
482
					$path = $file->getPath();
483
					$debugOutput->writeln("\e[1m $count \e[0m $fmtMemAfter \e[1m $memDelta \e[0m ($fmtMemDelta) $path");
484
				}
485
				$count++;
486
			} else {
487
				$this->logger->log("File with id $fileId not found for user $userId", 'warn');
488
			}
489
		}
490
491
		// reset execution time limit
492
		\set_time_limit($executionTime);
493
494
		return $count;
495
	}
496
497
	/**
498
	 * Check the availability of all the indexed audio files of the user. Remove
499
	 * from the index any which are not available.
500
	 * @param string $userId
501
	 * @param Folder $userHome
502
	 * @return Number of removed files
503
	 */
504
	public function removeUnavailableFiles($userId, $userHome) {
505
		$indexedFiles = $this->getScannedFileIds($userId);
506
		$unavailableFiles = [];
507
		foreach ($indexedFiles as $fileId) {
508
			$fileNodes = $userHome->getById($fileId);
509
			if (empty($fileNodes)) {
510
				$this->logger->log("File $fileId is not available for user $userId, removing", 'info');
511
				$unavailableFiles[] = $fileId;
512
			}
513
		}
514
515
		$count = \count($unavailableFiles);
516
		if ($count > 0) {
517
			$this->deleteAudio($unavailableFiles, [$userId]);
518
		}
519
		return $count;
520
	}
521
522
	/**
523
	 * Remove all such audio files from the collection which do not reside
524
	 * under the configured music path.
525
	 * @param string $userId
526
	 */
527
	private function removeFilesNotUnderMusicFolder($userId) {
528
		$indexedFiles = $this->getScannedFileIds($userId);
529
		$validFiles = Util::extractIds($this->getMusicFiles($userId));
530
		$filesToRemove = Util::arrayDiff($indexedFiles, $validFiles);
531
		if (\count($filesToRemove)) {
532
			$this->deleteAudio($filesToRemove, [$userId]);
533
		}
534
	}
535
536
	/**
537
	 * Parse and get basic info about a file. The file does not have to be indexed in the database.
538
	 * @param string $fileId
539
	 * @param string $userId
540
	 * @param Folder $userFolder
541
	 * $return array|null
542
	 */
543
	public function getFileInfo($fileId, $userId, $userFolder) {
544
		$info = $this->getIndexedFileInfo($fileId, $userId, $userFolder)
545
			?: $this->getUnindexedFileInfo($fileId, $userId, $userFolder);
546
547
		// base64-encode and wrap the cover image if available
548
		if ($info !== null && $info['cover'] !== null) {
549
			$mime = $info['cover']['mimetype'];
550
			$content = $info['cover']['content'];
551
			$info['cover'] = 'data:' . $mime. ';base64,' . \base64_encode($content);
552
		}
553
554
		return $info;
555
	}
556
557
	private function getIndexedFileInfo($fileId, $userId, $userFolder) {
558
		$track = $this->trackBusinessLayer->findByFileId($fileId, $userId);
559
		if ($track !== null) {
560
			$artist = $this->artistBusinessLayer->find($track->getArtistId(), $userId);
561
			$album = $this->albumBusinessLayer->find($track->getAlbumId(), $userId);
562
			return [
563
				'title'      => $track->getTitle(),
564
				'artist'     => $artist->getName(),
565
				'cover'      => $this->coverHelper->getCover($album, $userId, $userFolder),
566
				'in_library' => true
567
			];
568
		}
569
		return null;
570
	}
571
572
	private function getUnindexedFileInfo($fileId, $userId, $userFolder) {
573
		$fileNodes = $userFolder->getById($fileId);
574
		if (\count($fileNodes) > 0) {
575
			$file = $fileNodes[0];
576
			$metadata = $this->extractMetadata($file, $userFolder, $file->getPath());
577
			$cover = $metadata['picture'];
578
			if ($cover != null) {
579
				$cover = [
580
					'mimetype' => $cover['image_mime'],
581
					'content' => $this->coverHelper->scaleDownAndCrop($cover['data'], 200)
582
				];
583
			}
584
			return [
585
				'title'      => $metadata['title'],
586
				'artist'     => $metadata['artist'],
587
				'cover'      => $cover,
588
				'in_library' => $this->pathIsUnderMusicFolder($file->getPath(), $userId)
589
			];
590
		}
591
		return null;
592
	}
593
594
	/**
595
	 * Update music path
596
	 *
597
	 * @param string $oldPath
598
	 * @param string $newPath
599
	 * @param string $userId
600
	 */
601
	public function updatePath($oldPath, $newPath, $userId) {
602
		$this->logger->log("Changing music collection path of user $userId from $oldPath to $newPath", 'info');
603
604
		$userHome = $this->resolveUserFolder($userId);
605
606
		try {
607
			$oldFolder = Util::getFolderFromRelativePath($userHome, $oldPath);
608
			$newFolder = Util::getFolderFromRelativePath($userHome, $newPath);
609
610
			if ($newFolder->getPath() === $oldFolder->getPath()) {
611
				$this->logger->log('New collection path is the same as the old path, nothing to do', 'debug');
612
			} elseif ($newFolder->isSubNode($oldFolder)) {
613
				$this->logger->log('New collection path is (grand) parent of old path, previous content is still valid', 'debug');
614
			} elseif ($oldFolder->isSubNode($newFolder)) {
615
				$this->logger->log('Old collection path is (grand) parent of new path, checking the validity of previous content', 'debug');
616
				$this->removeFilesNotUnderMusicFolder($userId);
617
			} else {
618
				$this->logger->log('Old and new collection paths are unrelated, erasing the previous collection content', 'debug');
619
				$this->maintenance->resetDb($userId);
620
			}
621
		}
622
		catch (\OCP\Files\NotFoundException $e) {
623
			$this->logger->log('One of the paths was invalid, erasing the previous collection content', 'warn');
624
			$this->maintenance->resetDb($userId);
625
		}
626
627
	}
628
629
	/**
630
	 * Find external cover images for albums which do not yet have one.
631
	 * Target either one user or all users.
632
	 * @param string|null $userId
633
	 * @return true if any albums were updated
634
	 */
635
	public function findAlbumCovers($userId = null) {
636
		$affectedUsers = $this->albumBusinessLayer->findCovers($userId);
637
		// scratch the cache for those users whose music collection was touched
638
		foreach ($affectedUsers as $user) {
639
			$this->cache->remove($user, 'collection');
640
			$this->logger->log('album cover(s) were found for user '. $user, 'debug');
641
		}
642
		return !empty($affectedUsers);
0 ignored issues
show
Bug Best Practice introduced by
The expression return ! empty($affectedUsers) returns the type boolean which is incompatible with the documented return type true.
Loading history...
643
	}
644
645
	/**
646
	 * Find external cover images for albums which do not yet have one.
647
	 * Target either one user or all users.
648
	 * @param string|null $userId
649
	 * @return true if any albums were updated
650
	 */
651
	public function findArtistCovers($userId) {
652
		$allImages = $this->getImageFiles($userId);
653
		return $this->artistBusinessLayer->updateCovers($allImages, $userId);
654
	}
655
656
	public function resolveUserFolder($userId) {
657
		return $this->rootFolder->getUserFolder($userId);
658
	}
659
660
	private static function isNullOrEmpty($string) {
661
		return $string === null || $string === '';
662
	}
663
664
	private static function normalizeOrdinal($ordinal) {
665
		// convert format '1/10' to '1'
666
		$tmp = \explode('/', $ordinal);
667
		$ordinal = $tmp[0];
668
669
		// check for numeric values - cast them to int and verify it's a natural number above 0
670
		if (\is_numeric($ordinal) && ((int)$ordinal) > 0) {
671
			$ordinal = (int)$ordinal;
672
		} else {
673
			$ordinal = null;
674
		}
675
676
		return $ordinal;
677
	}
678
679
	private static function parseFileName($fileName) {
680
		// If the file name starts e.g like "12. something" or "12 - something", the
681
		// preceeding number is extracted as track number. Everything after the optional
682
		// track number + delimiters part but before the file extension is extracted as title.
683
		// The file extension consists of a '.' followed by 1-4 "word characters".
684
		if (\preg_match('/^((\d+)\s*[.-]\s+)?(.+)\.(\w{1,4})$/', $fileName, $matches) === 1) {
685
			return ['track_number' => $matches[2], 'title' => $matches[3]];
686
		} else {
687
			return ['track_number' => null, 'title' => $fileName];
688
		}
689
	}
690
691
	private static function normalizeYear($date) {
692
		if (\ctype_digit($date)) {
693
			return (int)$date; // the date is a valid year as-is
694
		} elseif (\preg_match('/^(\d\d\d\d)-\d\d-\d\d.*/', $date, $matches) === 1) {
695
			return (int)$matches[1]; // year from ISO-formatted date yyyy-mm-dd
696
		} else {
697
			return null;
698
		}
699
	}
700
701
	/**
702
	 * Loop through the tracks of an album and set the first track containing embedded cover art
703
	 * as cover file for the album
704
	 * @param int $albumId
705
	 * @param string|null $userId name of user, deducted from $albumId if omitted
706
	 * @param Folder|null $userFolder home folder of user, deducted from $userId if omitted
707
	 */
708
	private function findEmbeddedCoverForAlbum($albumId, $userId=null, $userFolder=null) {
709
		if ($userId === null) {
710
			$userId = $this->albumBusinessLayer->findAlbumOwner($albumId);
711
		}
712
		if ($userFolder === null) {
713
			$userFolder = $this->resolveUserFolder($userId);
714
		}
715
716
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $userId);
717
		foreach ($tracks as $track) {
718
			$nodes = $userFolder->getById($track->getFileId());
719
			if (\count($nodes) > 0) {
720
				// parse the first valid node and check if it contains embedded cover art
721
				$image = $this->extractor->parseEmbeddedCoverArt($nodes[0]);
722
				if ($image != null) {
723
					$this->albumBusinessLayer->setCover($track->getFileId(), $albumId);
724
					break;
725
				}
726
			}
727
		}
728
	}
729
}
730