Passed
Push — master ( c7cb4e...ce9a66 )
by Pauli
03:03
created

Scanner::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 28
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 14
nc 1
nop 14
dl 0
loc 28
rs 9.7998
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 - 2024
13
 */
14
15
namespace OCA\Music\Utility;
16
17
use OC\Hooks\PublicEmitter;
18
19
use OCP\Files\File;
20
use OCP\Files\Folder;
21
use OCP\Files\IRootFolder;
22
use OCP\IConfig;
23
use OCP\IL10N;
24
25
use OCA\Music\AppFramework\Core\Logger;
26
use OCA\Music\BusinessLayer\ArtistBusinessLayer;
27
use OCA\Music\BusinessLayer\AlbumBusinessLayer;
28
use OCA\Music\BusinessLayer\GenreBusinessLayer;
29
use OCA\Music\BusinessLayer\PlaylistBusinessLayer;
30
use OCA\Music\BusinessLayer\TrackBusinessLayer;
31
use OCA\Music\Db\Cache;
32
use OCA\Music\Db\Maintenance;
33
34
use Symfony\Component\Console\Output\OutputInterface;
35
36
class Scanner extends PublicEmitter {
37
	private $extractor;
38
	private $artistBusinessLayer;
39
	private $albumBusinessLayer;
40
	private $trackBusinessLayer;
41
	private $playlistBusinessLayer;
42
	private $genreBusinessLayer;
43
	private $cache;
44
	private $coverHelper;
45
	private $logger;
46
	private $maintenance;
47
	private $librarySettings;
48
	private $rootFolder;
49
	private $config;
50
	private $l10nFactory;
51
52
	public function __construct(Extractor $extractor,
53
								ArtistBusinessLayer $artistBusinessLayer,
54
								AlbumBusinessLayer $albumBusinessLayer,
55
								TrackBusinessLayer $trackBusinessLayer,
56
								PlaylistBusinessLayer $playlistBusinessLayer,
57
								GenreBusinessLayer $genreBusinessLayer,
58
								Cache $cache,
59
								CoverHelper $coverHelper,
60
								Logger $logger,
61
								Maintenance $maintenance,
62
								LibrarySettings $librarySettings,
63
								IRootFolder $rootFolder,
64
								IConfig $config,
65
								\OCP\L10N\IFactory $l10nFactory) {
66
		$this->extractor = $extractor;
67
		$this->artistBusinessLayer = $artistBusinessLayer;
68
		$this->albumBusinessLayer = $albumBusinessLayer;
69
		$this->trackBusinessLayer = $trackBusinessLayer;
70
		$this->playlistBusinessLayer = $playlistBusinessLayer;
71
		$this->genreBusinessLayer = $genreBusinessLayer;
72
		$this->cache = $cache;
73
		$this->coverHelper = $coverHelper;
74
		$this->logger = $logger;
75
		$this->maintenance = $maintenance;
76
		$this->librarySettings = $librarySettings;
77
		$this->rootFolder = $rootFolder;
78
		$this->config = $config;
79
		$this->l10nFactory = $l10nFactory;
80
	}
81
82
	/**
83
	 * Gets called by 'post_write' (file creation, file update) and 'post_share' hooks
84
	 */
85
	public function update(File $file, string $userId, string $filePath) : void {
86
		$mimetype = $file->getMimeType();
87
		$this->logger->log("update - $filePath - $mimetype", 'debug');
88
89
		if (!$this->librarySettings->pathBelongsToMusicLibrary($filePath, $userId)) {
90
			$this->logger->log("skipped - file is outside of specified music folder", 'debug');
91
		}
92
		elseif (Util::startsWith($mimetype, 'image')) {
93
			$this->updateImage($file, $userId);
94
		}
95
		elseif (Util::startsWith($mimetype, 'audio') && !self::isPlaylistMime($mimetype)) {
96
			$libraryRoot = $this->librarySettings->getFolder($userId);
97
			$this->updateAudio($file, $userId, $libraryRoot, $filePath, $mimetype, /*partOfScan=*/false);
98
		}
99
	}
100
101
	public function fileMoved(File $file, string $userId) : void {
102
		$mimetype = $file->getMimeType();
103
		$this->logger->log('fileMoved - '. $file->getPath() . " - $mimetype", 'debug');
104
105
		if (Util::startsWith($mimetype, 'image')) {
106
			// we don't need to track the identity of images and moving a file can be handled as it was 
107
			// a file deletion followed by a file addition
108
			$this->deleteImage([$file->getId()], [$userId]);
109
			$this->updateImage($file, $userId);
110
		}
111
		elseif (Util::startsWith($mimetype, 'audio') && !self::isPlaylistMime($mimetype)) {
112
			if ($this->librarySettings->pathBelongsToMusicLibrary($file->getPath(), $userId)) {
113
				// In the new path, the file (now or still) belongs to the library. Even if it was already in the lib,
114
				// the new path may have an influence on the album or artist name (in case of incomplete metadata).
115
				$libraryRoot = $this->librarySettings->getFolder($userId);
116
				$this->updateAudio($file, $userId, $libraryRoot, $file->getPath(), $mimetype, /*partOfScan=*/false);
117
			} else {
118
				// In the new path, the file doesn't (still or any longer) belong to the library. Remove it if
119
				// it happened to be in the library.
120
				$this->deleteAudio([$file->getId()], [$userId]);
121
			}
122
		}
123
	}
124
125
	public function folderMoved(Folder $folder, string $userId) : void {
126
		$this->logger->log('folderMoved - '. $folder->getPath(), 'debug');
127
128
		$audioFiles = $folder->searchByMime('audio');
129
130
		if (\count($audioFiles) > 0) {
131
			if ($this->librarySettings->pathBelongsToMusicLibrary($folder->getPath(), $userId)) {
132
				// The new path of the folder belongs to the library but this doesn't necessarily mean
133
				// that all the file paths below belong to the library, because of the path exclusions.
134
				// Each file needs to be checked and updated separately.
135
				if (\count($audioFiles) <= 15) {
136
					foreach ($audioFiles as $file) {
137
						\assert($file instanceof File); // a clue for PHPStan
138
						$this->fileMoved($file, $userId);
139
					}
140
				} else {
141
					// There are too many files to handle them now as we don't want to delay the move operation
142
					// too much. The user will be prompted to rescan the files upon opening the Music app.
143
					$this->trackBusinessLayer->markTracksDirty(Util::extractIds($audioFiles), [$userId]);
144
				}
145
			}
146
			else {
147
				// The new path of the folder doesn't belong to the library so neither does any of the
148
				// contained files. Remove audio files from the lib if found.
149
				$this->deleteAudio(Util::extractIds($audioFiles), [$userId]);
150
			}
151
		}
152
	}
153
154
	private static function isPlaylistMime(string $mime) : bool {
155
		return $mime == 'audio/mpegurl' || $mime == 'audio/x-scpls';
156
	}
157
158
	private function updateImage(File $file, string $userId) : void {
159
		$coverFileId = $file->getId();
160
		$parentFolderId = $file->getParent()->getId();
161
		if ($this->albumBusinessLayer->updateFolderCover($coverFileId, $parentFolderId)) {
162
			$this->logger->log('updateImage - the image was set as cover for some album(s)', 'debug');
163
			$this->cache->remove($userId, 'collection');
164
		}
165
166
		$artistIds = $this->artistBusinessLayer->updateCover($file, $userId, $this->userL10N($userId));
167
		foreach ($artistIds as $artistId) {
168
			$this->logger->log("updateImage - the image was set as cover for the artist $artistId", 'debug');
169
			$this->coverHelper->removeArtistCoverFromCache($artistId, $userId);
170
		}
171
	}
172
173
	private function updateAudio(File $file, string $userId, Folder $libraryRoot, string $filePath, string $mimetype, bool $partOfScan) : void {
174
		$this->emit(self::class, 'update', [$filePath]);
175
176
		$analysisEnabled = $this->librarySettings->getScanMetadataEnabled($userId);
177
		$meta = $this->extractMetadata($file, $libraryRoot, $filePath, $analysisEnabled);
178
		$fileId = $file->getId();
179
180
		// add/update artist and get artist entity
181
		$artist = $this->artistBusinessLayer->addOrUpdateArtist($meta['artist'], $userId);
182
		$artistId = $artist->getId();
183
184
		// add/update albumArtist and get artist entity
185
		$albumArtist = $this->artistBusinessLayer->addOrUpdateArtist($meta['albumArtist'], $userId);
186
		$albumArtistId = $albumArtist->getId();
187
188
		// add/update album and get album entity
189
		$album = $this->albumBusinessLayer->addOrUpdateAlbum($meta['album'], $albumArtistId, $userId);
190
		$albumId = $album->getId();
191
192
		// add/update genre and get genre entity
193
		$genre = $this->genreBusinessLayer->addOrUpdateGenre($meta['genre'], $userId);
194
195
		// add/update track and get track entity
196
		$track = $this->trackBusinessLayer->addOrUpdateTrack(
197
				$meta['title'], $meta['trackNumber'], $meta['discNumber'], $meta['year'], $genre->getId(),
198
				$artistId, $albumId, $fileId, $mimetype, $userId, $meta['length'], $meta['bitrate']);
199
200
		// if present, use the embedded album art as cover for the respective album
201
		if ($meta['picture'] != null) {
202
			// during scanning, don't repeatedly change the file providing the art for the album
203
			if ($album->getCoverFileId() === null || !$partOfScan) {
204
				$this->albumBusinessLayer->setCover($fileId, $albumId);
205
				$this->coverHelper->removeAlbumCoverFromCache($albumId, $userId);
206
			}
207
		}
208
		// if this file is an existing file which previously was used as cover for an album but now
209
		// the file no longer contains any embedded album art
210
		elseif ($album->getCoverFileId() === $fileId) {
211
			$this->albumBusinessLayer->removeCovers([$fileId]);
212
			$this->findEmbeddedCoverForAlbum($albumId, $userId, $libraryRoot);
213
			$this->coverHelper->removeAlbumCoverFromCache($albumId, $userId);
214
		}
215
216
		if (!$partOfScan) {
217
			// invalidate the cache as the music collection was changed
218
			$this->cache->remove($userId, 'collection');
219
		}
220
221
		$this->logger->log('imported entities - ' .
222
				"artist: $artistId, albumArtist: $albumArtistId, album: $albumId, track: {$track->getId()}",
223
				'debug');
224
	}
225
226
	private function extractMetadata(File $file, Folder $libraryRoot, string $filePath, bool $analyzeFile) : array {
227
		$fieldsFromFileName = self::parseFileName($file->getName());
228
		$fileInfo = $analyzeFile ? $this->extractor->extract($file) : [];
229
		$meta = [];
230
231
		// Track artist and album artist
232
		$meta['artist'] = ExtractorGetID3::getTag($fileInfo, 'artist');
233
		$meta['albumArtist'] = ExtractorGetID3::getFirstOfTags($fileInfo, ['band', 'albumartist', 'album artist', 'album_artist']);
234
235
		// use artist and albumArtist as fallbacks for each other
236
		if (!Util::isNonEmptyString($meta['albumArtist'])) {
237
			$meta['albumArtist'] = $meta['artist'];
238
		}
239
240
		if (!Util::isNonEmptyString($meta['artist'])) {
241
			$meta['artist'] = $meta['albumArtist'];
242
		}
243
244
		if (!Util::isNonEmptyString($meta['artist'])) {
245
			// neither artist nor albumArtist set in fileinfo, use the second level parent folder name
246
			// unless it is the user's library root folder
247
			$dirPath = \dirname(\dirname($filePath));
248
			if (Util::startsWith($libraryRoot->getPath(), $dirPath)) {
249
				$artistName = null;
250
			} else {
251
				$artistName = \basename($dirPath);
252
			}
253
254
			$meta['artist'] = $artistName;
255
			$meta['albumArtist'] = $artistName;
256
		}
257
258
		// title
259
		$meta['title'] = ExtractorGetID3::getTag($fileInfo, 'title');
260
		if (!Util::isNonEmptyString($meta['title'])) {
261
			$meta['title'] = $fieldsFromFileName['title'];
262
		}
263
264
		// album
265
		$meta['album'] = ExtractorGetID3::getTag($fileInfo, 'album');
266
		if (!Util::isNonEmptyString($meta['album'])) {
267
			// album name not set in fileinfo, use parent folder name as album name unless it is the user's library root folder
268
			$dirPath = \dirname($filePath);
269
			if ($libraryRoot->getPath() === $dirPath) {
270
				$meta['album'] = null;
271
			} else {
272
				$meta['album'] = \basename($dirPath);
273
			}
274
		}
275
276
		// track number
277
		$meta['trackNumber'] = ExtractorGetID3::getFirstOfTags($fileInfo, ['track_number', 'tracknumber', 'track'],
278
				$fieldsFromFileName['track_number']);
279
		$meta['trackNumber'] = self::normalizeOrdinal($meta['trackNumber']);
280
281
		// disc number
282
		$meta['discNumber'] = ExtractorGetID3::getFirstOfTags($fileInfo, ['disc_number', 'discnumber', 'part_of_a_set'], '1');
283
		$meta['discNumber'] = self::normalizeOrdinal($meta['discNumber']);
284
285
		// year
286
		$meta['year'] = ExtractorGetID3::getFirstOfTags($fileInfo, ['year', 'date', 'creation_date']);
287
		$meta['year'] = self::normalizeYear($meta['year']);
288
289
		$meta['genre'] = ExtractorGetID3::getTag($fileInfo, 'genre') ?: ''; // empty string used for "scanned but unknown"
290
291
		$meta['picture'] = ExtractorGetID3::getTag($fileInfo, 'picture', true);
292
293
		$meta['length'] = self::normalizeUnsigned($fileInfo['playtime_seconds'] ?? null);
294
295
		$meta['bitrate'] = self::normalizeUnsigned($fileInfo['audio']['bitrate'] ?? null);
296
297
		return $meta;
298
	}
299
300
	/**
301
	 * @param string[] $affectedUsers
302
	 * @param int[] $affectedAlbums
303
	 * @param int[] $affectedArtists
304
	 */
305
	private function invalidateCacheOnDelete(array $affectedUsers, array $affectedAlbums, array $affectedArtists) : void {
306
		// Delete may be for one file or for a folder containing thousands of albums.
307
		// If loads of albums got affected, then ditch the whole cache of the affected
308
		// users because removing the cached covers one-by-one could delay the delete
309
		// operation significantly.
310
		$albumCount = \count($affectedAlbums);
311
		$artistCount = \count($affectedArtists);
312
		$userCount = \count($affectedUsers);
313
314
		if ($albumCount + $artistCount > 100) {
315
			$this->logger->log("Delete operation affected $albumCount albums and $artistCount artists. " .
316
								"Invalidate the whole cache of all affected users ($userCount).", 'debug');
317
			foreach ($affectedUsers as $user) {
318
				$this->cache->remove($user);
319
			}
320
		} else {
321
			// remove the cached covers
322
			if ($artistCount > 0) {
323
				$this->logger->log("Remove covers of $artistCount artist(s) from the cache (if present)", 'debug');
324
				foreach ($affectedArtists as $artistId) {
325
					$this->coverHelper->removeArtistCoverFromCache($artistId);
326
				}
327
			}
328
329
			if ($albumCount > 0) {
330
				$this->logger->log("Remove covers of $albumCount album(s) from the cache (if present)", 'debug');
331
				foreach ($affectedAlbums as $albumId) {
332
					$this->coverHelper->removeAlbumCoverFromCache($albumId);
333
				}
334
			}
335
336
			// remove the cached collection regardless of if covers were affected; it may be that this
337
			// function got called after a track was deleted and there are no album/artist changes
338
			foreach ($affectedUsers as $user) {
339
				$this->cache->remove($user, 'collection');
340
			}
341
		}
342
	}
343
344
	/**
345
	 * @param int[] $fileIds
346
	 * @param string[]|null $userIds
347
	 * @return boolean true if anything was removed
348
	 */
349
	private function deleteAudio(array $fileIds, array $userIds=null) : bool {
350
		$this->logger->log('deleteAudio - '. \implode(', ', $fileIds), 'debug');
351
352
		$result = $this->trackBusinessLayer->deleteTracks($fileIds, $userIds);
353
354
		if ($result) { // one or more tracks were removed
355
			// remove obsolete artists and albums, and track references in playlists
356
			$this->albumBusinessLayer->deleteById($result['obsoleteAlbums']);
357
			$this->artistBusinessLayer->deleteById($result['obsoleteArtists']);
358
			$this->playlistBusinessLayer->removeTracksFromAllLists($result['deletedTracks']);
359
360
			// check if a removed track was used as embedded cover art file for a remaining album
361
			foreach ($result['remainingAlbums'] as $albumId) {
362
				if ($this->albumBusinessLayer->albumCoverIsOneOfFiles($albumId, $fileIds)) {
363
					$this->albumBusinessLayer->setCover(null, $albumId);
364
					$this->findEmbeddedCoverForAlbum($albumId);
365
					$this->coverHelper->removeAlbumCoverFromCache($albumId);
366
				}
367
			}
368
369
			$this->invalidateCacheOnDelete(
370
					$result['affectedUsers'], $result['obsoleteAlbums'], $result['obsoleteArtists']);
371
372
			$this->logger->log('removed entities - ' . \json_encode($result), 'debug');
373
			$this->emit(self::class, 'delete', [$result['deletedTracks'], $result['affectedUsers']]);
374
		}
375
376
		return $result !== false;
377
	}
378
379
	/**
380
	 * @param int[] $fileIds
381
	 * @param string[]|null $userIds
382
	 * @return boolean true if anything was removed
383
	 */
384
	private function deleteImage(array $fileIds, array $userIds=null) : bool {
385
		$this->logger->log('deleteImage - '. \implode(', ', $fileIds), 'debug');
386
387
		$affectedAlbums = $this->albumBusinessLayer->removeCovers($fileIds, $userIds);
388
		$affectedArtists = $this->artistBusinessLayer->removeCovers($fileIds, $userIds);
389
390
		$affectedUsers = \array_merge(
391
			Util::extractUserIds($affectedAlbums),
392
			Util::extractUserIds($affectedArtists)
393
		);
394
		$affectedUsers = \array_unique($affectedUsers);
395
396
		$this->invalidateCacheOnDelete(
397
				$affectedUsers, Util::extractIds($affectedAlbums), Util::extractIds($affectedArtists));
398
399
		return (\count($affectedAlbums) + \count($affectedArtists) > 0);
400
	}
401
402
	/**
403
	 * Gets called by 'unshare' hook and 'delete' hook
404
	 *
405
	 * @param int $fileId ID of the deleted files
406
	 * @param string[]|null $userIds the IDs of the users to remove the file from; if omitted,
407
	 *                               the file is removed from all users (ie. owner and sharees)
408
	 */
409
	public function delete(int $fileId, array $userIds=null) : void {
410
		if (!$this->deleteAudio([$fileId], $userIds) && !$this->deleteImage([$fileId], $userIds)) {
411
			$this->logger->log("deleted file $fileId was not an indexed " .
412
					'audio file or a cover image', 'debug');
413
		}
414
	}
415
416
	/**
417
	 * Remove all audio files and cover images in the given folder from the database.
418
	 * This gets called when a folder is deleted or unshared from the user.
419
	 *
420
	 * @param Folder $folder
421
	 * @param string[]|null $userIds the IDs of the users to remove the folder from; if omitted,
422
	 *                               the folder is removed from all users (ie. owner and sharees)
423
	 */
424
	public function deleteFolder(Folder $folder, array $userIds=null) : void {
425
		$audioFiles = $folder->searchByMime('audio');
426
		if (\count($audioFiles) > 0) {
427
			$this->deleteAudio(Util::extractIds($audioFiles), $userIds);
428
		}
429
430
		// NOTE: When a folder is removed, we don't need to check for any image
431
		// files in the folder. This is because those images could be potentially
432
		// used as covers only on the audio files of the same folder and those
433
		// were already removed above.
434
	}
435
436
	/**
437
	 * search for image files by mimetype inside user specified library path
438
	 * (which defaults to user home dir)
439
	 *
440
	 * @return File[]
441
	 */
442
	private function getImageFiles(string $userId) : array {
443
		try {
444
			$folder = $this->librarySettings->getFolder($userId);
445
		} catch (\OCP\Files\NotFoundException $e) {
446
			return [];
447
		}
448
449
		$images = $folder->searchByMime('image');
450
451
		// filter out any images in the excluded folders
452
		return \array_filter($images, function ($image) use ($userId) {
453
			return $this->librarySettings->pathBelongsToMusicLibrary($image->getPath(), $userId);
454
		});
455
	}
456
457
	private function getScannedFileIds(string $userId) : array {
458
		return $this->trackBusinessLayer->findAllFileIds($userId);
459
	}
460
461
	private function getMusicFolder(string $userId, ?string $path) {
462
		$folder = $this->librarySettings->getFolder($userId);
463
464
		if (!empty($path)) {
465
			$userFolder = $this->resolveUserFolder($userId);
466
			$requestedFolder = Util::getFolderFromRelativePath($userFolder, $path);
467
			if ($folder->isSubNode($requestedFolder) || $folder->getPath() == $requestedFolder->getPath()) {
468
				$folder = $requestedFolder;
469
			} else {
470
				throw new \OCP\Files\NotFoundException();
471
			}
472
		}
473
474
		return $folder;
475
	}
476
477
	/**
478
	 * Search for music files by mimetype inside user specified library path
479
	 * (which defaults to user home dir). Exclude given array of IDs.
480
	 * Optionally, limit the search to only the specified path. If this path doesn't
481
	 * point within the library path, then nothing will be found.
482
	 *
483
	 * @param int[] $excludeIds
484
	 * @return int[]
485
	 */
486
	private function getAllMusicFileIdsExcluding(string $userId, ?string $path, array $excludeIds) : array {
487
		try {
488
			$folder = $this->getMusicFolder($userId, $path);
489
		} catch (\OCP\Files\NotFoundException $e) {
490
			return [];
491
		}
492
493
		// Search files with mime 'audio/*' but filter out the playlist files and files under excluded folders
494
		$files = $folder->searchByMime('audio');
495
496
		// Look-up-table of IDs to be excluded from the final result
497
		$excludeIdsLut = \array_flip($excludeIds);
498
499
		$files = \array_filter($files, function ($f) use ($userId, $excludeIdsLut) {
500
			return !isset($excludeIdsLut[$f->getId()])
501
					&& !self::isPlaylistMime($f->getMimeType())
502
					&& $this->librarySettings->pathBelongsToMusicLibrary($f->getPath(), $userId);
503
		});
504
505
		return \array_values(Util::extractIds($files)); // the array may be sparse before array_values
506
	}
507
508
	public function getAllMusicFileIds(string $userId, ?string $path = null) : array {
509
		return $this->getAllMusicFileIdsExcluding($userId, $path, []);
510
	}
511
512
	public function getUnscannedMusicFileIds(string $userId, ?string $path = null) : array {
513
		$scannedIds = $this->getScannedFileIds($userId);
514
		$unscannedIds = $this->getAllMusicFileIdsExcluding($userId, $path, $scannedIds);
515
516
		$count = \count($unscannedIds);
517
		if ($count) {
518
			$this->logger->log("Found $count unscanned music files for user $userId", 'info');
519
		} else {
520
			$this->logger->log("No unscanned music files for user $userId", 'debug');
521
		}
522
523
		return $unscannedIds;
524
	}
525
526
	/**
527
	 * Find already scanned music files which have been modified since the time they were scanned
528
	 *
529
	 * @return int[]
530
	 */
531
	public function getDirtyMusicFileIds(string $userId, ?string $path = null) : array {
532
		$tracks = $this->trackBusinessLayer->findAllDirty($userId);
533
		$fileIds = Util::arrayMapMethod($tracks, 'getFileId');
534
535
		// filter by path if given
536
		if (!empty($path)) {
537
			try {
538
				$folder = $this->getMusicFolder($userId, $path);
539
			} catch (\OCP\Files\NotFoundException $e) {
540
				return [];
541
			}
542
			$fileIds = \array_filter($fileIds, function(int $fileId) use ($folder) {
543
				return \count($folder->getById($fileId)) > 0;
544
			});
545
		}
546
547
		return \array_values($fileIds); // make the array non-sparse
548
	}
549
550
	public function scanFiles(string $userId, array $fileIds, OutputInterface $debugOutput = null) : int {
551
		$count = \count($fileIds);
552
		$this->logger->log("Scanning $count files of user $userId", 'debug');
553
554
		// back up the execution time limit
555
		$executionTime = \intval(\ini_get('max_execution_time'));
556
		// set execution time limit to unlimited
557
		\set_time_limit(0);
558
559
		$libraryRoot = $this->librarySettings->getFolder($userId);
560
561
		$count = 0;
562
		foreach ($fileIds as $fileId) {
563
			$file = $libraryRoot->getById($fileId)[0] ?? null;
564
			if ($file != null && !$this->librarySettings->pathBelongsToMusicLibrary($file->getPath(), $userId)) {
565
				$this->emit(self::class, 'exclude', [$file->getPath()]);
566
				$file = null;
567
			}
568
			if ($file instanceof File) {
569
				$memBefore = $debugOutput ? \memory_get_usage(true) : 0;
570
				$this->updateAudio($file, $userId, $libraryRoot, $file->getPath(), $file->getMimetype(), /*partOfScan=*/true);
571
				if ($debugOutput) {
572
					$memAfter = \memory_get_usage(true);
573
					$memDelta = $memAfter - $memBefore;
574
					$fmtMemAfter = Util::formatFileSize($memAfter);
575
					$fmtMemDelta = Util::formatFileSize($memDelta);
576
					$path = $file->getPath();
577
					$debugOutput->writeln("\e[1m $count \e[0m $fmtMemAfter \e[1m $memDelta \e[0m ($fmtMemDelta) $path");
578
				}
579
				$count++;
580
			} else {
581
				$this->logger->log("File with id $fileId not found for user $userId, removing it from the library if present", 'info');
582
				$this->deleteAudio([$fileId], [$userId]);
583
			}
584
		}
585
586
		// reset execution time limit
587
		\set_time_limit($executionTime);
588
589
		// invalidate the cache as the collection has changed
590
		$this->cache->remove($userId, 'collection');
591
592
		return $count;
593
	}
594
595
	/**
596
	 * Check the availability of all the indexed audio files of the user. Remove
597
	 * from the index any which are not available.
598
	 * @return int Number of removed files
599
	 */
600
	public function removeUnavailableFiles(string $userId) : int {
601
		$indexedFiles = $this->getScannedFileIds($userId);
602
		$availableFiles = $this->getAllMusicFileIds($userId);
603
		$unavailableFiles = Util::arrayDiff($indexedFiles, $availableFiles);
604
605
		$count = \count($unavailableFiles);
606
		if ($count > 0) {
607
			$this->logger->log('The following files are no longer available within the library of the '.
608
				"user $userId, removing: " . /** @scrutinizer ignore-type */ \print_r($unavailableFiles, true), 'info');
609
			$this->deleteAudio($unavailableFiles, [$userId]);
610
		}
611
		return $count;
612
	}
613
614
	/**
615
	 * Parse and get basic info about a file. The file does not have to be indexed in the database.
616
	 */
617
	public function getFileInfo(int $fileId, string $userId, Folder $userFolder) : ?array {
618
		$info = $this->getIndexedFileInfo($fileId, $userId, $userFolder)
619
			?: $this->getUnindexedFileInfo($fileId, $userId, $userFolder);
620
621
		// base64-encode and wrap the cover image if available
622
		if ($info !== null && $info['cover'] !== null) {
623
			$mime = $info['cover']['mimetype'];
624
			$content = $info['cover']['content'];
625
			$info['cover'] = 'data:' . $mime. ';base64,' . \base64_encode($content);
626
		}
627
628
		return $info;
629
	}
630
631
	private function getIndexedFileInfo(int $fileId, string $userId, Folder $userFolder) : ?array {
632
		$track = $this->trackBusinessLayer->findByFileId($fileId, $userId);
633
		if ($track !== null) {
634
			$artist = $this->artistBusinessLayer->find($track->getArtistId(), $userId);
635
			$album = $this->albumBusinessLayer->find($track->getAlbumId(), $userId);
636
			return [
637
				'title'      => $track->getTitle(),
638
				'artist'     => $artist->getName(),
639
				'cover'      => $this->coverHelper->getCover($album, $userId, $userFolder),
640
				'in_library' => true
641
			];
642
		}
643
		return null;
644
	}
645
646
	private function getUnindexedFileInfo(int $fileId, string $userId, Folder $userFolder) : ?array {
647
		$file = $userFolder->getById($fileId)[0] ?? null;
648
		if ($file instanceof File) {
649
			$metadata = $this->extractMetadata($file, $userFolder, $file->getPath(), true);
650
			$cover = $metadata['picture'];
651
			if ($cover != null) {
652
				$cover = $this->coverHelper->scaleDownAndCrop([
653
					'mimetype' => $cover['image_mime'],
654
					'content' => $cover['data']
655
				], 200);
656
			}
657
			return [
658
				'title'      => $metadata['title'],
659
				'artist'     => $metadata['artist'],
660
				'cover'      => $cover,
661
				'in_library' => $this->librarySettings->pathBelongsToMusicLibrary($file->getPath(), $userId)
662
			];
663
		}
664
		return null;
665
	}
666
667
	/**
668
	 * Update music path
669
	 */
670
	public function updatePath(string $oldPath, string $newPath, string $userId) : void {
671
		$this->logger->log("Changing music collection path of user $userId from $oldPath to $newPath", 'info');
672
673
		$userHome = $this->resolveUserFolder($userId);
674
675
		try {
676
			$oldFolder = Util::getFolderFromRelativePath($userHome, $oldPath);
677
			$newFolder = Util::getFolderFromRelativePath($userHome, $newPath);
678
679
			if ($newFolder->getPath() === $oldFolder->getPath()) {
680
				$this->logger->log('New collection path is the same as the old path, nothing to do', 'debug');
681
			} elseif ($newFolder->isSubNode($oldFolder)) {
682
				$this->logger->log('New collection path is (grand) parent of old path, previous content is still valid', 'debug');
683
			} elseif ($oldFolder->isSubNode($newFolder)) {
684
				$this->logger->log('Old collection path is (grand) parent of new path, checking the validity of previous content', 'debug');
685
				$this->removeUnavailableFiles($userId);
686
			} else {
687
				$this->logger->log('Old and new collection paths are unrelated, erasing the previous collection content', 'debug');
688
				$this->maintenance->resetLibrary($userId);
689
			}
690
		} catch (\OCP\Files\NotFoundException $e) {
691
			$this->logger->log('One of the paths was invalid, erasing the previous collection content', 'warn');
692
			$this->maintenance->resetLibrary($userId);
693
		}
694
	}
695
696
	/**
697
	 * Find external cover images for albums which do not yet have one.
698
	 * Target either one user or all users.
699
	 * @param string|null $userId
700
	 * @return bool true if any albums were updated; false otherwise
701
	 */
702
	public function findAlbumCovers(string $userId = null) : bool {
703
		$affectedUsers = $this->albumBusinessLayer->findCovers($userId);
704
		// scratch the cache for those users whose music collection was touched
705
		foreach ($affectedUsers as $user) {
706
			$this->cache->remove($user, 'collection');
707
			$this->logger->log('album cover(s) were found for user '. $user, 'debug');
708
		}
709
		return !empty($affectedUsers);
710
	}
711
712
	/**
713
	 * Find external cover images for artists which do not yet have one.
714
	 * @param string $userId
715
	 * @return bool true if any albums were updated; false otherwise
716
	 */
717
	public function findArtistCovers(string $userId) : bool {
718
		$allImages = $this->getImageFiles($userId);
719
		return $this->artistBusinessLayer->updateCovers($allImages, $userId, $this->userL10N($userId));
720
	}
721
722
	public function resolveUserFolder(string $userId) : Folder {
723
		return $this->rootFolder->getUserFolder($userId);
724
	}
725
726
	/**
727
	 * Get the selected localization of the user, even in case there is no logged in user in the context.
728
	 */
729
	private function userL10N(string $userId) : IL10N {
730
		$languageCode = $this->config->getUserValue($userId, 'core', 'lang');
731
		return $this->l10nFactory->get('music', $languageCode);
732
	}
733
734
	/**
735
	 * @param int|float|string|null $ordinal
736
	 * @return int|float|null
737
	 */
738
	private static function normalizeOrdinal(/*mixed*/ $ordinal) {
739
		if (\is_string($ordinal)) {
740
			// convert format '1/10' to '1'
741
			$ordinal = \explode('/', $ordinal)[0];
742
		}
743
744
		// check for numeric values - cast them to int and verify it's a natural number above 0
745
		if (\is_numeric($ordinal) && ((int)$ordinal) > 0) {
746
			$ordinal = (int)$ordinal;
747
		} else {
748
			$ordinal = null;
749
		}
750
751
		return Util::limit($ordinal, 0, Util::SINT32_MAX); // can't use UINT32_MAX since PostgreSQL has no unsigned types
752
	}
753
754
	private static function parseFileName(string $fileName) : array {
755
		$matches = null;
756
		// If the file name starts e.g like "12. something" or "12 - something", the
757
		// preceding number is extracted as track number. Everything after the optional
758
		// track number + delimiters part but before the file extension is extracted as title.
759
		// The file extension consists of a '.' followed by 1-4 "word characters".
760
		if (\preg_match('/^((\d+)\s*[.-]\s+)?(.+)\.(\w{1,4})$/', $fileName, $matches) === 1) {
761
			return ['track_number' => $matches[2], 'title' => $matches[3]];
762
		} else {
763
			return ['track_number' => null, 'title' => $fileName];
764
		}
765
	}
766
767
	/**
768
	 * @param int|float|string|null $date
769
	 * @return int|float|null
770
	 */
771
	private static function normalizeYear(/*mixed*/ $date) {
772
		$year = null;
773
		$matches = null;
774
775
		if (\is_numeric($date)) {
776
			$year = (int)$date; // the date is a valid year as-is
777
		} elseif (\is_string($date) && \preg_match('/^(\d\d\d\d)-\d\d-\d\d.*/', $date, $matches) === 1) {
778
			$year = (int)$matches[1]; // year from ISO-formatted date yyyy-mm-dd
779
		} else {
780
			$year = null;
781
		}
782
783
		return Util::limit($year, Util::SINT32_MIN, Util::SINT32_MAX);
784
	}
785
786
	/**
787
	 * @param int|float|string|null $value
788
	 * @return int|float|null
789
	 */
790
	private static function normalizeUnsigned(/*mixed*/ $value) {
791
		if (\is_numeric($value)) {
792
			$value = (int)\round((float)$value);
793
		} else {
794
			$value = null;
795
		}
796
		return Util::limit($value, 0, Util::SINT32_MAX); // can't use UINT32_MAX since PostgreSQL has no unsigned types
797
	}
798
799
	/**
800
	 * Loop through the tracks of an album and set the first track containing embedded cover art
801
	 * as cover file for the album
802
	 * @param int $albumId
803
	 * @param string|null $userId name of user, deducted from $albumId if omitted
804
	 * @param Folder|null $baseFolder base folder for the search, library root of $userId is used if omitted
805
	 */
806
	private function findEmbeddedCoverForAlbum(int $albumId, string $userId=null, Folder $baseFolder=null) : void {
807
		if ($userId === null) {
808
			$userId = $this->albumBusinessLayer->findAlbumOwner($albumId);
809
		}
810
		if ($baseFolder === null) {
811
			$baseFolder = $this->librarySettings->getFolder($userId);
812
		}
813
814
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $userId);
815
		foreach ($tracks as $track) {
816
			$nodes = $baseFolder->getById($track->getFileId());
817
			if (\count($nodes) > 0) {
818
				// parse the first valid node and check if it contains embedded cover art
819
				$image = $this->extractor->parseEmbeddedCoverArt($nodes[0]);
820
				if ($image != null) {
821
					$this->albumBusinessLayer->setCover($track->getFileId(), $albumId);
822
					break;
823
				}
824
			}
825
		}
826
	}
827
}
828