Passed
Push — master ( d80bc5...fffa45 )
by Pauli
03:31
created

Scanner   F

Complexity

Total Complexity 122

Size/Duplication

Total Lines 821
Duplicated Lines 0 %

Importance

Changes 4
Bugs 0 Features 0
Metric Value
eloc 393
c 4
b 0
f 0
dl 0
loc 821
rs 2
wmc 122

35 Methods

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

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