Passed
Push — master ( 022c2a...9c43c9 )
by Pauli
02:58
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 but this may take too much time
135
				// if there is extensive number of files.
136
				if (\count($audioFiles) <= 30) {
137
					foreach ($audioFiles as $file) {
138
						$this->fileMoved($file, $userId);
139
					}
140
				} else {
141
					// Remove the scanned files to get them rescanned when the Music app is opened.
142
					// TODO: Better handling e.g. by marking the files as dirty.
143
					$this->deleteAudio(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('\OCA\Music\Utility\Scanner', '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
		$this->emit('\OCA\Music\Utility\Scanner', 'delete', [$fileIds, $userIds]);
352
353
		$result = $this->trackBusinessLayer->deleteTracks($fileIds, $userIds);
354
355
		if ($result) { // one or more tracks were removed
356
			// remove obsolete artists and albums, and track references in playlists
357
			$this->albumBusinessLayer->deleteById($result['obsoleteAlbums']);
358
			$this->artistBusinessLayer->deleteById($result['obsoleteArtists']);
359
			$this->playlistBusinessLayer->removeTracksFromAllLists($result['deletedTracks']);
360
361
			// check if a removed track was used as embedded cover art file for a remaining album
362
			foreach ($result['remainingAlbums'] as $albumId) {
363
				if ($this->albumBusinessLayer->albumCoverIsOneOfFiles($albumId, $fileIds)) {
364
					$this->albumBusinessLayer->setCover(null, $albumId);
365
					$this->findEmbeddedCoverForAlbum($albumId);
366
					$this->coverHelper->removeAlbumCoverFromCache($albumId);
367
				}
368
			}
369
370
			$this->invalidateCacheOnDelete(
371
					$result['affectedUsers'], $result['obsoleteAlbums'], $result['obsoleteArtists']);
372
373
			$this->logger->log('removed entities - ' . \json_encode($result), 'debug');
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 instanceof File) {
565
				$memBefore = $debugOutput ? \memory_get_usage(true) : 0;
566
				$this->updateAudio($file, $userId, $libraryRoot, $file->getPath(), $file->getMimetype(), /*partOfScan=*/true);
567
				if ($debugOutput) {
568
					$memAfter = \memory_get_usage(true);
569
					$memDelta = $memAfter - $memBefore;
570
					$fmtMemAfter = Util::formatFileSize($memAfter);
571
					$fmtMemDelta = Util::formatFileSize($memDelta);
572
					$path = $file->getPath();
573
					$debugOutput->writeln("\e[1m $count \e[0m $fmtMemAfter \e[1m $memDelta \e[0m ($fmtMemDelta) $path");
574
				}
575
				$count++;
576
			} else {
577
				$this->logger->log("File with id $fileId not found for user $userId", 'warn');
578
			}
579
		}
580
581
		// reset execution time limit
582
		\set_time_limit($executionTime);
583
584
		// invalidate the cache as the collection has changed
585
		$this->cache->remove($userId, 'collection');
586
587
		return $count;
588
	}
589
590
	/**
591
	 * Check the availability of all the indexed audio files of the user. Remove
592
	 * from the index any which are not available.
593
	 * @return int Number of removed files
594
	 */
595
	public function removeUnavailableFiles(string $userId) : int {
596
		$indexedFiles = $this->getScannedFileIds($userId);
597
		$availableFiles = $this->getAllMusicFileIds($userId);
598
		$unavailableFiles = Util::arrayDiff($indexedFiles, $availableFiles);
599
600
		$count = \count($unavailableFiles);
601
		if ($count > 0) {
602
			$this->logger->log('The following files are no longer available within the library of the '.
603
				"user $userId, removing: " . /** @scrutinizer ignore-type */ \print_r($unavailableFiles, true), 'info');
604
			$this->deleteAudio($unavailableFiles, [$userId]);
605
		}
606
		return $count;
607
	}
608
609
	/**
610
	 * Parse and get basic info about a file. The file does not have to be indexed in the database.
611
	 */
612
	public function getFileInfo(int $fileId, string $userId, Folder $userFolder) : ?array {
613
		$info = $this->getIndexedFileInfo($fileId, $userId, $userFolder)
614
			?: $this->getUnindexedFileInfo($fileId, $userId, $userFolder);
615
616
		// base64-encode and wrap the cover image if available
617
		if ($info !== null && $info['cover'] !== null) {
618
			$mime = $info['cover']['mimetype'];
619
			$content = $info['cover']['content'];
620
			$info['cover'] = 'data:' . $mime. ';base64,' . \base64_encode($content);
621
		}
622
623
		return $info;
624
	}
625
626
	private function getIndexedFileInfo(int $fileId, string $userId, Folder $userFolder) : ?array {
627
		$track = $this->trackBusinessLayer->findByFileId($fileId, $userId);
628
		if ($track !== null) {
629
			$artist = $this->artistBusinessLayer->find($track->getArtistId(), $userId);
630
			$album = $this->albumBusinessLayer->find($track->getAlbumId(), $userId);
631
			return [
632
				'title'      => $track->getTitle(),
633
				'artist'     => $artist->getName(),
634
				'cover'      => $this->coverHelper->getCover($album, $userId, $userFolder),
635
				'in_library' => true
636
			];
637
		}
638
		return null;
639
	}
640
641
	private function getUnindexedFileInfo(int $fileId, string $userId, Folder $userFolder) : ?array {
642
		$file = $userFolder->getById($fileId)[0] ?? null;
643
		if ($file instanceof File) {
644
			$metadata = $this->extractMetadata($file, $userFolder, $file->getPath(), true);
645
			$cover = $metadata['picture'];
646
			if ($cover != null) {
647
				$cover = $this->coverHelper->scaleDownAndCrop([
648
					'mimetype' => $cover['image_mime'],
649
					'content' => $cover['data']
650
				], 200);
651
			}
652
			return [
653
				'title'      => $metadata['title'],
654
				'artist'     => $metadata['artist'],
655
				'cover'      => $cover,
656
				'in_library' => $this->librarySettings->pathBelongsToMusicLibrary($file->getPath(), $userId)
657
			];
658
		}
659
		return null;
660
	}
661
662
	/**
663
	 * Update music path
664
	 */
665
	public function updatePath(string $oldPath, string $newPath, string $userId) : void {
666
		$this->logger->log("Changing music collection path of user $userId from $oldPath to $newPath", 'info');
667
668
		$userHome = $this->resolveUserFolder($userId);
669
670
		try {
671
			$oldFolder = Util::getFolderFromRelativePath($userHome, $oldPath);
672
			$newFolder = Util::getFolderFromRelativePath($userHome, $newPath);
673
674
			if ($newFolder->getPath() === $oldFolder->getPath()) {
675
				$this->logger->log('New collection path is the same as the old path, nothing to do', 'debug');
676
			} elseif ($newFolder->isSubNode($oldFolder)) {
677
				$this->logger->log('New collection path is (grand) parent of old path, previous content is still valid', 'debug');
678
			} elseif ($oldFolder->isSubNode($newFolder)) {
679
				$this->logger->log('Old collection path is (grand) parent of new path, checking the validity of previous content', 'debug');
680
				$this->removeUnavailableFiles($userId);
681
			} else {
682
				$this->logger->log('Old and new collection paths are unrelated, erasing the previous collection content', 'debug');
683
				$this->maintenance->resetLibrary($userId);
684
			}
685
		} catch (\OCP\Files\NotFoundException $e) {
686
			$this->logger->log('One of the paths was invalid, erasing the previous collection content', 'warn');
687
			$this->maintenance->resetLibrary($userId);
688
		}
689
	}
690
691
	/**
692
	 * Find external cover images for albums which do not yet have one.
693
	 * Target either one user or all users.
694
	 * @param string|null $userId
695
	 * @return bool true if any albums were updated; false otherwise
696
	 */
697
	public function findAlbumCovers(string $userId = null) : bool {
698
		$affectedUsers = $this->albumBusinessLayer->findCovers($userId);
699
		// scratch the cache for those users whose music collection was touched
700
		foreach ($affectedUsers as $user) {
701
			$this->cache->remove($user, 'collection');
702
			$this->logger->log('album cover(s) were found for user '. $user, 'debug');
703
		}
704
		return !empty($affectedUsers);
705
	}
706
707
	/**
708
	 * Find external cover images for artists which do not yet have one.
709
	 * @param string $userId
710
	 * @return bool true if any albums were updated; false otherwise
711
	 */
712
	public function findArtistCovers(string $userId) : bool {
713
		$allImages = $this->getImageFiles($userId);
714
		return $this->artistBusinessLayer->updateCovers($allImages, $userId, $this->userL10N($userId));
715
	}
716
717
	public function resolveUserFolder(string $userId) : Folder {
718
		return $this->rootFolder->getUserFolder($userId);
719
	}
720
721
	/**
722
	 * Get the selected localization of the user, even in case there is no logged in user in the context.
723
	 */
724
	private function userL10N(string $userId) : IL10N {
725
		$languageCode = $this->config->getUserValue($userId, 'core', 'lang');
726
		return $this->l10nFactory->get('music', $languageCode);
727
	}
728
729
	/**
730
	 * @param int|float|string|null $ordinal
731
	 * @return int|float|null
732
	 */
733
	private static function normalizeOrdinal(/*mixed*/ $ordinal) {
734
		if (\is_string($ordinal)) {
735
			// convert format '1/10' to '1'
736
			$ordinal = \explode('/', $ordinal)[0];
737
		}
738
739
		// check for numeric values - cast them to int and verify it's a natural number above 0
740
		if (\is_numeric($ordinal) && ((int)$ordinal) > 0) {
741
			$ordinal = (int)$ordinal;
742
		} else {
743
			$ordinal = null;
744
		}
745
746
		return Util::limit($ordinal, 0, Util::SINT32_MAX); // can't use UINT32_MAX since PostgreSQL has no unsigned types
747
	}
748
749
	private static function parseFileName(string $fileName) : array {
750
		$matches = null;
751
		// If the file name starts e.g like "12. something" or "12 - something", the
752
		// preceding number is extracted as track number. Everything after the optional
753
		// track number + delimiters part but before the file extension is extracted as title.
754
		// The file extension consists of a '.' followed by 1-4 "word characters".
755
		if (\preg_match('/^((\d+)\s*[.-]\s+)?(.+)\.(\w{1,4})$/', $fileName, $matches) === 1) {
756
			return ['track_number' => $matches[2], 'title' => $matches[3]];
757
		} else {
758
			return ['track_number' => null, 'title' => $fileName];
759
		}
760
	}
761
762
	/**
763
	 * @param int|float|string|null $date
764
	 * @return int|float|null
765
	 */
766
	private static function normalizeYear(/*mixed*/ $date) {
767
		$year = null;
768
		$matches = null;
769
770
		if (\is_numeric($date)) {
771
			$year = (int)$date; // the date is a valid year as-is
772
		} elseif (\is_string($date) && \preg_match('/^(\d\d\d\d)-\d\d-\d\d.*/', $date, $matches) === 1) {
773
			$year = (int)$matches[1]; // year from ISO-formatted date yyyy-mm-dd
774
		} else {
775
			$year = null;
776
		}
777
778
		return Util::limit($year, Util::SINT32_MIN, Util::SINT32_MAX);
779
	}
780
781
	/**
782
	 * @param int|float|string|null $value
783
	 * @return int|float|null
784
	 */
785
	private static function normalizeUnsigned(/*mixed*/ $value) {
786
		if (\is_numeric($value)) {
787
			$value = (int)\round((float)$value);
788
		} else {
789
			$value = null;
790
		}
791
		return Util::limit($value, 0, Util::SINT32_MAX); // can't use UINT32_MAX since PostgreSQL has no unsigned types
792
	}
793
794
	/**
795
	 * Loop through the tracks of an album and set the first track containing embedded cover art
796
	 * as cover file for the album
797
	 * @param int $albumId
798
	 * @param string|null $userId name of user, deducted from $albumId if omitted
799
	 * @param Folder|null $baseFolder base folder for the search, library root of $userId is used if omitted
800
	 */
801
	private function findEmbeddedCoverForAlbum(int $albumId, string $userId=null, Folder $baseFolder=null) : void {
802
		if ($userId === null) {
803
			$userId = $this->albumBusinessLayer->findAlbumOwner($albumId);
804
		}
805
		if ($baseFolder === null) {
806
			$baseFolder = $this->librarySettings->getFolder($userId);
807
		}
808
809
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $userId);
810
		foreach ($tracks as $track) {
811
			$nodes = $baseFolder->getById($track->getFileId());
812
			if (\count($nodes) > 0) {
813
				// parse the first valid node and check if it contains embedded cover art
814
				$image = $this->extractor->parseEmbeddedCoverArt($nodes[0]);
815
				if ($image != null) {
816
					$this->albumBusinessLayer->setCover($track->getFileId(), $albumId);
817
					break;
818
				}
819
			}
820
		}
821
	}
822
}
823