Passed
Push — master ( 871d9f...5c9ff5 )
by Pauli
02:53
created

Scanner::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 24
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 12
nc 1
nop 12
dl 0
loc 24
rs 9.8666
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 - 2021
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
74
	/**
75
	 * Gets called by 'post_write' (file creation, file update) and 'post_share' hooks
76
	 * @param File $file the file
77
	 * @param string $userId
78
	 * @param Folder $userHome
79
	 * @param string|null $filePath Deduced from $file if not given
80
	 */
81
	public function update(File $file, string $userId, Folder $userHome, string $filePath = null) : void {
82
		if ($filePath === null) {
83
			$filePath = $file->getPath();
84
		}
85
86
		// debug logging
87
		$this->logger->log("update - $filePath", 'debug');
88
89
		// skip files that aren't inside the user specified path
90
		if (!$this->userMusicFolder->pathBelongsToMusicLibrary($filePath, $userId)) {
91
			$this->logger->log("skipped - file is outside of specified music folder", 'debug');
92
			return;
93
		}
94
95
		$mimetype = $file->getMimeType();
96
97
		// debug logging
98
		$this->logger->log("update - mimetype $mimetype", 'debug');
99
100
		if (Util::startsWith($mimetype, 'image')) {
101
			$this->updateImage($file, $userId);
102
		} elseif (Util::startsWith($mimetype, 'audio') && !self::isPlaylistMime($mimetype)) {
103
			$this->updateAudio($file, $userId, $userHome, $filePath, $mimetype);
104
		}
105
	}
106
107
	private static function isPlaylistMime(string $mime) : bool {
108
		return $mime == 'audio/mpegurl' || $mime == 'audio/x-scpls';
109
	}
110
111
	private function updateImage(File $file, string $userId) : void {
112
		$coverFileId = $file->getId();
113
		$parentFolderId = $file->getParent()->getId();
114
		if ($this->albumBusinessLayer->updateFolderCover($coverFileId, $parentFolderId)) {
115
			$this->logger->log('updateImage - the image was set as cover for some album(s)', 'debug');
116
			$this->cache->remove($userId, 'collection');
117
		}
118
		if ($artistId = $this->artistBusinessLayer->updateCover($file, $userId)) {
119
			$this->logger->log("updateImage - the image was set as cover for the artist $artistId", 'debug');
120
			$this->coverHelper->removeArtistCoverFromCache($artistId, $userId);
121
		}
122
	}
123
124
	private function updateAudio(File $file, string $userId, Folder $userHome, string $filePath, string $mimetype) : void {
125
		$this->emit('\OCA\Music\Utility\Scanner', 'update', [$filePath]);
126
127
		$meta = $this->extractMetadata($file, $userHome, $filePath);
128
		$fileId = $file->getId();
129
130
		// add/update artist and get artist entity
131
		$artist = $this->artistBusinessLayer->addOrUpdateArtist($meta['artist'], $userId);
132
		$artistId = $artist->getId();
133
134
		// add/update albumArtist and get artist entity
135
		$albumArtist = $this->artistBusinessLayer->addOrUpdateArtist($meta['albumArtist'], $userId);
136
		$albumArtistId = $albumArtist->getId();
137
138
		// add/update album and get album entity
139
		$album = $this->albumBusinessLayer->addOrUpdateAlbum(
140
				$meta['album'], $albumArtistId, $userId);
141
		$albumId = $album->getId();
142
143
		// add/update genre and get genre entity
144
		$genre = $this->genreBusinessLayer->addOrUpdateGenre($meta['genre'], $userId);
145
146
		// add/update track and get track entity
147
		$track = $this->trackBusinessLayer->addOrUpdateTrack(
148
				$meta['title'], $meta['trackNumber'], $meta['discNumber'], $meta['year'], $genre->getId(),
149
				$artistId, $albumId, $fileId, $mimetype, $userId, $meta['length'], $meta['bitrate']);
150
151
		// if present, use the embedded album art as cover for the respective album
152
		if ($meta['picture'] != null) {
153
			$this->albumBusinessLayer->setCover($fileId, $albumId);
154
			$this->coverHelper->removeAlbumCoverFromCache($albumId, $userId);
155
		}
156
		// if this file is an existing file which previously was used as cover for an album but now
157
		// the file no longer contains any embedded album art
158
		elseif ($this->albumBusinessLayer->albumCoverIsOneOfFiles($albumId, [$fileId])) {
159
			$this->albumBusinessLayer->removeCovers([$fileId]);
160
			$this->findEmbeddedCoverForAlbum($albumId, $userId, $userHome);
161
			$this->coverHelper->removeAlbumCoverFromCache($albumId, $userId);
162
		}
163
164
		// invalidate the cache as the music collection was changed
165
		$this->cache->remove($userId, 'collection');
166
167
		// debug logging
168
		$this->logger->log('imported entities - ' .
169
				"artist: $artistId, albumArtist: $albumArtistId, album: $albumId, track: {$track->getId()}",
170
				'debug');
171
	}
172
173
	private function extractMetadata(File $file, Folder $userHome, string $filePath) : array {
174
		$fieldsFromFileName = self::parseFileName($file->getName());
175
		$fileInfo = $this->extractor->extract($file);
176
		$meta = [];
177
178
		// Track artist and album artist
179
		$meta['artist'] = ExtractorGetID3::getTag($fileInfo, 'artist');
180
		$meta['albumArtist'] = ExtractorGetID3::getFirstOfTags($fileInfo, ['band', 'albumartist', 'album artist', 'album_artist']);
181
182
		// use artist and albumArtist as fallbacks for each other
183
		if (!Util::isNonEmptyString($meta['albumArtist'])) {
184
			$meta['albumArtist'] = $meta['artist'];
185
		}
186
187
		if (!Util::isNonEmptyString($meta['artist'])) {
188
			$meta['artist'] = $meta['albumArtist'];
189
		}
190
191
		if (!Util::isNonEmptyString($meta['artist'])) {
192
			// neither artist nor albumArtist set in fileinfo, use the second level parent folder name
193
			// unless it is the user root folder
194
			$dirPath = \dirname(\dirname($filePath));
195
			if (Util::startsWith($userHome->getPath(), $dirPath)) {
196
				$artistName = null;
197
			} else {
198
				$artistName = \basename($dirPath);
199
			}
200
201
			$meta['artist'] = $artistName;
202
			$meta['albumArtist'] = $artistName;
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 image files by mimetype inside user specified library path
388
	 * (which defaults to user home dir)
389
	 *
390
	 * @return File[]
391
	 */
392
	private function getImageFiles(string $userId) : array {
393
		try {
394
			$folder = $this->userMusicFolder->getFolder($userId);
395
		} catch (\OCP\Files\NotFoundException $e) {
396
			return [];
397
		}
398
399
		$images = $folder->searchByMime('image');
400
401
		// filter out any images in the excluded folders
402
		return \array_filter($images, function ($image) use ($userId) {
403
			return $this->userMusicFolder->pathBelongsToMusicLibrary($image->getPath(), $userId);
404
		});
405
	}
406
407
	private function getScannedFileIds(string $userId) : array {
408
		return $this->trackBusinessLayer->findAllFileIds($userId);
409
	}
410
411
	/**
412
	 * Search for music files by mimetype inside user specified library path
413
	 * (which defaults to user home dir). Exclude given array of IDs.
414
	 * Optionally, limit the search to only the specified path. If this path doesn't
415
	 * point within the library path, then nothing will be found.
416
	 *
417
	 * @param int[] $excludeIds
418
	 * @return int[]
419
	 */
420
	private function getAllMusicFileIdsExcluding(string $userId, string $path = null, array $excludeIds) : array {
421
		try {
422
			$folder = $this->userMusicFolder->getFolder($userId);
423
424
			if (!empty($path)) {
425
				$userFolder = $this->resolveUserFolder($userId);
426
				$requestedFolder = Util::getFolderFromRelativePath($userFolder, $path);
427
				if ($folder->isSubNode($requestedFolder) || $folder->getPath() == $requestedFolder->getPath()) {
428
					$folder = $requestedFolder;
429
				} else {
430
					throw new \OCP\Files\NotFoundException();
431
				}
432
			}
433
		} catch (\OCP\Files\NotFoundException $e) {
434
			return [];
435
		}
436
437
		// Search files with mime 'audio/*' but filter out the playlist files and files under excluded folders
438
		$files = $folder->searchByMime('audio');
439
440
		// Look-up-table of IDs to be excluded from the final result
441
		$excludeIdsLut = \array_flip($excludeIds);
442
443
		$files = \array_filter($files, function ($f) use ($userId, $excludeIdsLut) {
444
			return !isset($excludeIdsLut[$f->getId()])
445
					&& !self::isPlaylistMime($f->getMimeType())
446
					&& $this->userMusicFolder->pathBelongsToMusicLibrary($f->getPath(), $userId);
447
		});
448
449
		return \array_values(Util::extractIds($files)); // the array may be sparse before array_values
450
	}
451
452
	public function getAllMusicFileIds(string $userId, string $path = null) : array {
453
		return $this->getAllMusicFileIdsExcluding($userId, $path, []);
454
	}
455
456
	public function getUnscannedMusicFileIds(string $userId, string $path = null) : array {
457
		$scannedIds = $this->getScannedFileIds($userId);
458
		$unscannedIds = $this->getAllMusicFileIdsExcluding($userId, $path, $scannedIds);
459
460
		$count = \count($unscannedIds);
461
		if ($count) {
462
			$this->logger->log("Found $count unscanned music files for user $userId", 'info');
463
		} else {
464
			$this->logger->log("No unscanned music files for user $userId", 'debug');
465
		}
466
467
		return $unscannedIds;
468
	}
469
470
	public function scanFiles(string $userId, Folder $userHome, array $fileIds, OutputInterface $debugOutput = null) : int {
471
		$count = \count($fileIds);
472
		$this->logger->log("Scanning $count files of user $userId", 'debug');
473
474
		// back up the execution time limit
475
		$executionTime = \intval(\ini_get('max_execution_time'));
476
		// set execution time limit to unlimited
477
		\set_time_limit(0);
478
479
		$count = 0;
480
		foreach ($fileIds as $fileId) {
481
			$fileNodes = $userHome->getById($fileId);
482
			if (\count($fileNodes) > 0) {
483
				$file = $fileNodes[0];
484
				$memBefore = $debugOutput ? \memory_get_usage(true) : 0;
485
				$this->update($file, $userId, $userHome);
486
				if ($debugOutput) {
487
					$memAfter = \memory_get_usage(true);
488
					$memDelta = $memAfter - $memBefore;
489
					$fmtMemAfter = Util::formatFileSize($memAfter);
490
					$fmtMemDelta = Util::formatFileSize($memDelta);
491
					$path = $file->getPath();
492
					$debugOutput->writeln("\e[1m $count \e[0m $fmtMemAfter \e[1m $memDelta \e[0m ($fmtMemDelta) $path");
493
				}
494
				$count++;
495
			} else {
496
				$this->logger->log("File with id $fileId not found for user $userId", 'warn');
497
			}
498
		}
499
500
		// reset execution time limit
501
		\set_time_limit($executionTime);
502
503
		return $count;
504
	}
505
506
	/**
507
	 * Check the availability of all the indexed audio files of the user. Remove
508
	 * from the index any which are not available.
509
	 * @return Number of removed files
510
	 */
511
	public function removeUnavailableFiles(string $userId) : int {
512
		$indexedFiles = $this->getScannedFileIds($userId);
513
		$availableFiles = $this->getAllMusicFileIds($userId);
514
		$unavailableFiles = Util::arrayDiff($indexedFiles, $availableFiles);
515
516
		$count = \count($unavailableFiles);
517
		if ($count > 0) {
518
			$this->logger->log('The following files are no longer available within the library of the '.
519
				"user $userId, removing: " . /** @scrutinizer ignore-type */ \print_r($unavailableFiles, true), 'info');
520
			$this->deleteAudio($unavailableFiles, [$userId]);
521
		}
522
		return $count;
523
	}
524
525
	/**
526
	 * Parse and get basic info about a file. The file does not have to be indexed in the database.
527
	 */
528
	public function getFileInfo(int $fileId, string $userId, Folder $userFolder) : ?array {
529
		$info = $this->getIndexedFileInfo($fileId, $userId, $userFolder)
530
			?: $this->getUnindexedFileInfo($fileId, $userId, $userFolder);
531
532
		// base64-encode and wrap the cover image if available
533
		if ($info !== null && $info['cover'] !== null) {
534
			$mime = $info['cover']['mimetype'];
535
			$content = $info['cover']['content'];
536
			$info['cover'] = 'data:' . $mime. ';base64,' . \base64_encode($content);
537
		}
538
539
		return $info;
540
	}
541
542
	private function getIndexedFileInfo(int $fileId, string $userId, Folder $userFolder) : ?array {
543
		$track = $this->trackBusinessLayer->findByFileId($fileId, $userId);
544
		if ($track !== null) {
545
			$artist = $this->artistBusinessLayer->find($track->getArtistId(), $userId);
546
			$album = $this->albumBusinessLayer->find($track->getAlbumId(), $userId);
547
			return [
548
				'title'      => $track->getTitle(),
549
				'artist'     => $artist->getName(),
550
				'cover'      => $this->coverHelper->getCover($album, $userId, $userFolder),
551
				'in_library' => true
552
			];
553
		}
554
		return null;
555
	}
556
557
	private function getUnindexedFileInfo(int $fileId, string $userId, Folder $userFolder) : ?array {
558
		$fileNodes = $userFolder->getById($fileId);
559
		if (\count($fileNodes) > 0) {
560
			$file = $fileNodes[0];
561
			$metadata = $this->extractMetadata($file, $userFolder, $file->getPath());
562
			$cover = $metadata['picture'];
563
			if ($cover != null) {
564
				$cover = [
565
					'mimetype' => $cover['image_mime'],
566
					'content' => $this->coverHelper->scaleDownAndCrop($cover['data'], 200)
567
				];
568
			}
569
			return [
570
				'title'      => $metadata['title'],
571
				'artist'     => $metadata['artist'],
572
				'cover'      => $cover,
573
				'in_library' => $this->userMusicFolder->pathBelongsToMusicLibrary($file->getPath(), $userId)
574
			];
575
		}
576
		return null;
577
	}
578
579
	/**
580
	 * Update music path
581
	 */
582
	public function updatePath(string $oldPath, string $newPath, string $userId) : void {
583
		$this->logger->log("Changing music collection path of user $userId from $oldPath to $newPath", 'info');
584
585
		$userHome = $this->resolveUserFolder($userId);
586
587
		try {
588
			$oldFolder = Util::getFolderFromRelativePath($userHome, $oldPath);
589
			$newFolder = Util::getFolderFromRelativePath($userHome, $newPath);
590
591
			if ($newFolder->getPath() === $oldFolder->getPath()) {
592
				$this->logger->log('New collection path is the same as the old path, nothing to do', 'debug');
593
			} elseif ($newFolder->isSubNode($oldFolder)) {
594
				$this->logger->log('New collection path is (grand) parent of old path, previous content is still valid', 'debug');
595
			} elseif ($oldFolder->isSubNode($newFolder)) {
596
				$this->logger->log('Old collection path is (grand) parent of new path, checking the validity of previous content', 'debug');
597
				$this->removeUnavailableFiles($userId);
598
			} else {
599
				$this->logger->log('Old and new collection paths are unrelated, erasing the previous collection content', 'debug');
600
				$this->maintenance->resetDb($userId);
601
			}
602
		} catch (\OCP\Files\NotFoundException $e) {
603
			$this->logger->log('One of the paths was invalid, erasing the previous collection content', 'warn');
604
			$this->maintenance->resetDb($userId);
605
		}
606
	}
607
608
	/**
609
	 * Find external cover images for albums which do not yet have one.
610
	 * Target either one user or all users.
611
	 * @param string|null $userId
612
	 * @return bool true if any albums were updated; false otherwise
613
	 */
614
	public function findAlbumCovers(string $userId = null) : bool {
615
		$affectedUsers = $this->albumBusinessLayer->findCovers($userId);
616
		// scratch the cache for those users whose music collection was touched
617
		foreach ($affectedUsers as $user) {
618
			$this->cache->remove($user, 'collection');
619
			$this->logger->log('album cover(s) were found for user '. $user, 'debug');
620
		}
621
		return !empty($affectedUsers);
622
	}
623
624
	/**
625
	 * Find external cover images for albums which do not yet have one.
626
	 * @param string $userId
627
	 * @return bool true if any albums were updated; false otherwise
628
	 */
629
	public function findArtistCovers(string $userId) : bool {
630
		$allImages = $this->getImageFiles($userId);
631
		return $this->artistBusinessLayer->updateCovers($allImages, $userId);
632
	}
633
634
	public function resolveUserFolder(string $userId) : Folder {
635
		return $this->rootFolder->getUserFolder($userId);
636
	}
637
638
	private static function normalizeOrdinal($ordinal) : ?int {
639
		if (\is_string($ordinal)) {
640
			// convert format '1/10' to '1'
641
			$ordinal = \explode('/', $ordinal)[0];
642
		}
643
644
		// check for numeric values - cast them to int and verify it's a natural number above 0
645
		if (\is_numeric($ordinal) && ((int)$ordinal) > 0) {
646
			$ordinal = (int)$ordinal;
647
		} else {
648
			$ordinal = null;
649
		}
650
651
		return $ordinal;
652
	}
653
654
	private static function parseFileName(string $fileName) : array {
655
		$matches = null;
656
		// If the file name starts e.g like "12. something" or "12 - something", the
657
		// preceeding number is extracted as track number. Everything after the optional
658
		// track number + delimiters part but before the file extension is extracted as title.
659
		// The file extension consists of a '.' followed by 1-4 "word characters".
660
		if (\preg_match('/^((\d+)\s*[.-]\s+)?(.+)\.(\w{1,4})$/', $fileName, $matches) === 1) {
661
			return ['track_number' => $matches[2], 'title' => $matches[3]];
662
		} else {
663
			return ['track_number' => null, 'title' => $fileName];
664
		}
665
	}
666
667
	private static function normalizeYear($date) : ?int {
668
		$matches = null;
669
670
		if (\ctype_digit($date)) {
671
			return (int)$date; // the date is a valid year as-is
672
		} elseif (\is_string($date) && \preg_match('/^(\d\d\d\d)-\d\d-\d\d.*/', $date, $matches) === 1) {
673
			return (int)$matches[1]; // year from ISO-formatted date yyyy-mm-dd
674
		} else {
675
			return null;
676
		}
677
	}
678
679
	/**
680
	 * Loop through the tracks of an album and set the first track containing embedded cover art
681
	 * as cover file for the album
682
	 * @param int $albumId
683
	 * @param string|null $userId name of user, deducted from $albumId if omitted
684
	 * @param Folder|null $userFolder home folder of user, deducted from $userId if omitted
685
	 */
686
	private function findEmbeddedCoverForAlbum(int $albumId, string $userId=null, Folder $userFolder=null) : void {
687
		if ($userId === null) {
688
			$userId = $this->albumBusinessLayer->findAlbumOwner($albumId);
689
		}
690
		if ($userFolder === null) {
691
			$userFolder = $this->resolveUserFolder($userId);
692
		}
693
694
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $userId);
695
		foreach ($tracks as $track) {
696
			$nodes = $userFolder->getById($track->getFileId());
697
			if (\count($nodes) > 0) {
698
				// parse the first valid node and check if it contains embedded cover art
699
				$image = $this->extractor->parseEmbeddedCoverArt($nodes[0]);
700
				if ($image != null) {
701
					$this->albumBusinessLayer->setCover($track->getFileId(), $albumId);
702
					break;
703
				}
704
			}
705
		}
706
	}
707
}
708