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