Passed
Push — master ( 543ba8...434da3 )
by Pauli
01:50
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);
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