Passed
Push — master ( 543ba8...434da3 )
by Pauli
01:50
created

Scanner::extractMetadata()   B

Complexity

Conditions 9
Paths 96

Size

Total Lines 67
Code Lines 36

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 90

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 9
eloc 36
c 2
b 0
f 0
nc 96
nop 3
dl 0
loc 67
ccs 0
cts 36
cp 0
crap 90
rs 8.0555

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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