Passed
Push — master ( 7b8fea...bb5769 )
by Pauli
10:49
created

Scanner::__construct()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 29
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

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