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

Scanner   F

Complexity

Total Complexity 118

Size/Duplication

Total Lines 787
Duplicated Lines 0 %

Importance

Changes 5
Bugs 0 Features 0
Metric Value
eloc 370
dl 0
loc 787
rs 2
c 5
b 0
f 0
wmc 118

35 Methods

Rating   Name   Duplication   Size   Complexity  
A __construct() 0 28 1
A isPlaylistMime() 0 2 2
A getScannedFileIds() 0 2 1
A updateImage() 0 12 3
A getDirtyMusicFileIds() 0 17 3
A getMusicFolder() 0 14 4
A deleteAudio() 0 28 4
A getImageFiles() 0 12 2
A getAllMusicFileIdsExcluding() 0 20 4
A deleteImage() 0 16 1
A delete() 0 4 3
A fileMoved() 0 20 5
B invalidateCacheOnDelete() 0 35 8
A update() 0 13 5
A getAllMusicFileIds() 0 2 1
A deleteFolder() 0 4 2
B extractMetadata() 0 72 10
B updateAudio() 0 51 6
A getUnscannedMusicFileIds() 0 12 2
A findAlbumCovers() 0 8 2
A getIndexedFileInfo() 0 13 2
A removeUnavailableFiles() 0 12 2
A resolveUserFolder() 0 2 1
A findArtistCovers() 0 3 1
A normalizeOrdinal() 0 14 4
A normalizeYear() 0 13 4
A updatePath() 0 23 5
A folderMoved() 0 25 5
A parseFileName() 0 10 2
B scanFiles() 0 43 7
A userL10N() 0 3 1
A normalizeUnsigned() 0 7 2
A getFileInfo() 0 12 4
A getUnindexedFileInfo() 0 19 3
A findEmbeddedCoverForAlbum() 0 17 6

How to fix   Complexity   

Complex Class

Complex classes like Scanner often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Scanner, and based on these observations, apply Extract Interface, too.

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