Scanner::__construct()   A
last analyzed

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 - 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(ExtractorGetID3 $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->debug("audio or image file within lib path updated: $filePath");
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->debug('image file moved: '. $file->getPath());
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->debug('audio file moved: '. $file->getPath());
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->debug("folder with $audioCount audio files moved: ". $folder->getPath());
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->debug('updateImage - the image was set as cover for some album(s)');
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->debug("updateImage - the image was set as cover for the artist $artistId");
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->debug('imported entities - ' .
237
				"artist: $artistId, albumArtist: $albumArtistId, album: $albumId, track: {$track->getId()}");
238
239
		return [
240
			'analyze' => $time2 - $time1,
241
			'db update' => $time3 - $time2
242
		];
243
	}
244
245
	private function extractMetadata(File $file, Folder $libraryRoot, string $filePath, bool $analyzeFile) : array {
246
		$fieldsFromFileName = self::parseFileName($file->getName());
247
		$fileInfo = $analyzeFile ? $this->extractor->extract($file) : [];
248
		$meta = [];
249
250
		// Track artist and album artist
251
		$meta['artist'] = ExtractorGetID3::getTag($fileInfo, 'artist');
252
		$meta['albumArtist'] = ExtractorGetID3::getFirstOfTags($fileInfo, ['band', 'albumartist', 'album artist', 'album_artist']);
253
254
		// use artist and albumArtist as fallbacks for each other
255
		if (!StringUtil::isNonEmptyString($meta['albumArtist'])) {
256
			$meta['albumArtist'] = $meta['artist'];
257
		}
258
259
		if (!StringUtil::isNonEmptyString($meta['artist'])) {
260
			$meta['artist'] = $meta['albumArtist'];
261
		}
262
263
		if (!StringUtil::isNonEmptyString($meta['artist'])) {
264
			// neither artist nor albumArtist set in fileinfo, use the second level parent folder name
265
			// unless it is the user's library root folder
266
			$dirPath = \dirname(\dirname($filePath));
267
			if (StringUtil::startsWith($libraryRoot->getPath(), $dirPath)) {
268
				$artistName = null;
269
			} else {
270
				$artistName = \basename($dirPath);
271
			}
272
273
			$meta['artist'] = $artistName;
274
			$meta['albumArtist'] = $artistName;
275
		}
276
277
		// title
278
		$meta['title'] = ExtractorGetID3::getTag($fileInfo, 'title');
279
		if (!StringUtil::isNonEmptyString($meta['title'])) {
280
			$meta['title'] = $fieldsFromFileName['title'];
281
		}
282
283
		// album
284
		$meta['album'] = ExtractorGetID3::getTag($fileInfo, 'album');
285
		if (!StringUtil::isNonEmptyString($meta['album'])) {
286
			// album name not set in fileinfo, use parent folder name as album name unless it is the user's library root folder
287
			$dirPath = \dirname($filePath);
288
			if ($libraryRoot->getPath() === $dirPath) {
289
				$meta['album'] = null;
290
			} else {
291
				$meta['album'] = \basename($dirPath);
292
			}
293
		}
294
295
		// track number
296
		$meta['trackNumber'] = ExtractorGetID3::getFirstOfTags($fileInfo, ['track_number', 'tracknumber', 'track'],
297
				$fieldsFromFileName['track_number']);
298
		$meta['trackNumber'] = self::normalizeOrdinal($meta['trackNumber']);
299
300
		// disc number
301
		$meta['discNumber'] = ExtractorGetID3::getFirstOfTags($fileInfo, ['disc_number', 'discnumber', 'part_of_a_set'], '1');
302
		$meta['discNumber'] = self::normalizeOrdinal($meta['discNumber']);
303
304
		// year
305
		$meta['year'] = ExtractorGetID3::getFirstOfTags($fileInfo, ['year', 'date', 'creation_date']);
306
		$meta['year'] = self::normalizeYear($meta['year']);
307
308
		$meta['genre'] = ExtractorGetID3::getTag($fileInfo, 'genre') ?: ''; // empty string used for "scanned but unknown"
309
310
		$meta['picture'] = ExtractorGetID3::getTag($fileInfo, 'picture', true);
311
312
		$meta['length'] = self::normalizeUnsigned($fileInfo['playtime_seconds'] ?? null);
313
314
		$meta['bitrate'] = self::normalizeUnsigned($fileInfo['audio']['bitrate'] ?? null);
315
316
		return $meta;
317
	}
318
319
	/**
320
	 * @param string[] $affectedUsers
321
	 * @param int[] $affectedAlbums
322
	 * @param int[] $affectedArtists
323
	 */
324
	private function invalidateCacheOnDelete(array $affectedUsers, array $affectedAlbums, array $affectedArtists) : void {
325
		// Delete may be for one file or for a folder containing thousands of albums.
326
		// If loads of albums got affected, then ditch the whole cache of the affected
327
		// users because removing the cached covers one-by-one could delay the delete
328
		// operation significantly.
329
		$albumCount = \count($affectedAlbums);
330
		$artistCount = \count($affectedArtists);
331
		$userCount = \count($affectedUsers);
332
333
		if ($albumCount + $artistCount > 100) {
334
			$this->logger->debug("Delete operation affected $albumCount albums and $artistCount artists. " .
335
								"Invalidate the whole cache of all affected users ($userCount).");
336
			foreach ($affectedUsers as $user) {
337
				$this->cache->remove($user);
338
			}
339
		} else {
340
			// remove the cached covers
341
			if ($artistCount > 0) {
342
				$this->logger->debug("Remove covers of $artistCount artist(s) from the cache (if present)");
343
				foreach ($affectedArtists as $artistId) {
344
					$this->coverService->removeArtistCoverFromCache($artistId);
345
				}
346
			}
347
348
			if ($albumCount > 0) {
349
				$this->logger->debug("Remove covers of $albumCount album(s) from the cache (if present)");
350
				foreach ($affectedAlbums as $albumId) {
351
					$this->coverService->removeAlbumCoverFromCache($albumId);
352
				}
353
			}
354
355
			// remove the cached collection regardless of if covers were affected; it may be that this
356
			// function got called after a track was deleted and there are no album/artist changes
357
			foreach ($affectedUsers as $user) {
358
				$this->cache->remove($user, 'collection');
359
			}
360
		}
361
	}
362
363
	/**
364
	 * @param int[] $fileIds
365
	 * @param string[]|null $userIds
366
	 * @return boolean true if anything was removed
367
	 */
368
	private function deleteAudio(array $fileIds, ?array $userIds=null) : bool {
369
		$result = $this->trackBusinessLayer->deleteTracks($fileIds, $userIds);
370
371
		if ($result) { // one or more tracks were removed
372
			$this->logger->debug('library updated when audio file(s) removed: '. \implode(', ', $fileIds));
373
374
			// remove obsolete artists and albums, and track references in playlists
375
			$this->albumBusinessLayer->deleteById($result['obsoleteAlbums']);
376
			$this->artistBusinessLayer->deleteById($result['obsoleteArtists']);
377
			$this->playlistBusinessLayer->removeTracksFromAllLists($result['deletedTracks']);
378
379
			// check if a removed track was used as embedded cover art file for a remaining album
380
			foreach ($result['remainingAlbums'] as $albumId) {
381
				if ($this->albumBusinessLayer->albumCoverIsOneOfFiles($albumId, $fileIds)) {
382
					$this->albumBusinessLayer->setCover(null, $albumId);
383
					$this->findEmbeddedCoverForAlbum($albumId);
384
					$this->coverService->removeAlbumCoverFromCache($albumId);
385
				}
386
			}
387
388
			$this->invalidateCacheOnDelete(
389
					$result['affectedUsers'], $result['obsoleteAlbums'], $result['obsoleteArtists']);
390
391
			$this->logger->debug('removed entities: ' . \json_encode($result));
392
			$this->emit(self::class, 'delete', [$result['deletedTracks'], $result['affectedUsers']]);
393
		}
394
395
		return $result !== false;
396
	}
397
398
	/**
399
	 * @param int[] $fileIds
400
	 * @param string[]|null $userIds
401
	 * @return boolean true if anything was removed
402
	 */
403
	private function deleteImage(array $fileIds, ?array $userIds=null) : bool {
404
		$affectedAlbums = $this->albumBusinessLayer->removeCovers($fileIds, $userIds);
405
		$affectedArtists = $this->artistBusinessLayer->removeCovers($fileIds, $userIds);
406
407
		$anythingAffected = (\count($affectedAlbums) + \count($affectedArtists) > 0);
408
409
		if ($anythingAffected) {
410
			$this->logger->debug('library covers updated when image file(s) removed: '. \implode(', ', $fileIds));
411
412
			$affectedUsers = \array_merge(
413
				ArrayUtil::extractUserIds($affectedAlbums),
414
				ArrayUtil::extractUserIds($affectedArtists)
415
			);
416
			$affectedUsers = \array_unique($affectedUsers);
417
418
			$this->invalidateCacheOnDelete(
419
					$affectedUsers, ArrayUtil::extractIds($affectedAlbums), ArrayUtil::extractIds($affectedArtists));
420
		}
421
422
		return $anythingAffected;
423
	}
424
425
	/**
426
	 * Gets called by 'unshare' hook and 'delete' hook
427
	 *
428
	 * @param int $fileId ID of the deleted files
429
	 * @param string[]|null $userIds the IDs of the users to remove the file from; if omitted,
430
	 *                               the file is removed from all users (ie. owner and sharees)
431
	 */
432
	public function delete(int $fileId, ?array $userIds=null) : void {
433
		// The removed file may or may not be of interesting type and belong to the library. It's
434
		// most efficient just to try to remove it as audio or image. It will take just a few simple
435
		// DB queries to notice if the file had nothing to do with our library.
436
		if (!$this->deleteAudio([$fileId], $userIds)) {
437
			$this->deleteImage([$fileId], $userIds);
438
		}
439
	}
440
441
	/**
442
	 * Remove all audio files and cover images in the given folder from the database.
443
	 * This gets called when a folder is deleted or unshared from the user.
444
	 *
445
	 * @param Folder $folder
446
	 * @param string[]|null $userIds the IDs of the users to remove the folder from; if omitted,
447
	 *                               the folder is removed from all users (ie. owner and sharees)
448
	 */
449
	public function deleteFolder(Folder $folder, ?array $userIds=null) : void {
450
		$audioFiles = $folder->searchByMime('audio');
451
		if (\count($audioFiles) > 0) {
452
			$this->deleteAudio(ArrayUtil::extractIds($audioFiles), $userIds);
453
		}
454
455
		// NOTE: When a folder is removed, we don't need to check for any image
456
		// files in the folder. This is because those images could be potentially
457
		// used as covers only on the audio files of the same folder and those
458
		// were already removed above.
459
	}
460
461
	/**
462
	 * search for image files by mimetype inside user specified library path
463
	 * (which defaults to user home dir)
464
	 *
465
	 * @return File[]
466
	 */
467
	private function getImageFiles(string $userId) : array {
468
		try {
469
			$folder = $this->librarySettings->getFolder($userId);
470
		} catch (\OCP\Files\NotFoundException $e) {
471
			return [];
472
		}
473
474
		$images = $folder->searchByMime('image');
475
476
		// filter out any images in the excluded folders
477
		return \array_filter($images, function ($image) use ($userId) {
478
			return ($image instanceof File) // assure PHPStan that Node indeed is File
479
				&& $this->librarySettings->pathBelongsToMusicLibrary($image->getPath(), $userId);
480
		});
481
	}
482
483
	private function getScannedFileIds(string $userId) : array {
484
		return $this->trackBusinessLayer->findAllFileIds($userId);
485
	}
486
487
	private function getMusicFolder(string $userId, ?string $path) : Folder {
488
		$folder = $this->librarySettings->getFolder($userId);
489
490
		if (!empty($path)) {
491
			$userFolder = $this->resolveUserFolder($userId);
492
			$requestedFolder = FilesUtil::getFolderFromRelativePath($userFolder, $path);
493
			if ($folder->isSubNode($requestedFolder) || $folder->getPath() == $requestedFolder->getPath()) {
494
				$folder = $requestedFolder;
495
			} else {
496
				throw new \OCP\Files\NotFoundException();
497
			}
498
		}
499
500
		return $folder;
501
	}
502
503
	/**
504
	 * Search for music files by mimetype inside user specified library path
505
	 * (which defaults to user home dir). Exclude given array of IDs.
506
	 * Optionally, limit the search to only the specified path. If this path doesn't
507
	 * point within the library path, then nothing will be found.
508
	 *
509
	 * @param int[] $excludeIds
510
	 * @return int[]
511
	 */
512
	private function getAllMusicFileIdsExcluding(string $userId, ?string $path, array $excludeIds) : array {
513
		try {
514
			$folder = $this->getMusicFolder($userId, $path);
515
		} catch (\OCP\Files\NotFoundException $e) {
516
			return [];
517
		}
518
519
		// Search files with mime 'audio/*' but filter out the playlist files and files under excluded folders
520
		$files = $folder->searchByMime('audio');
521
522
		// Look-up-table of IDs to be excluded from the final result
523
		$excludeIdsLut = \array_flip($excludeIds);
524
525
		$files = \array_filter($files, function ($f) use ($userId, $excludeIdsLut) {
526
			return !isset($excludeIdsLut[$f->getId()])
527
					&& !self::isPlaylistMime($f->getMimeType())
528
					&& $this->librarySettings->pathBelongsToMusicLibrary($f->getPath(), $userId);
529
		});
530
531
		return \array_values(ArrayUtil::extractIds($files)); // the array may be sparse before array_values
532
	}
533
534
	public function getAllMusicFileIds(string $userId, ?string $path = null) : array {
535
		return $this->getAllMusicFileIdsExcluding($userId, $path, []);
536
	}
537
538
	public function getUnscannedMusicFileIds(string $userId, ?string $path = null) : array {
539
		$scannedIds = $this->getScannedFileIds($userId);
540
		$unscannedIds = $this->getAllMusicFileIdsExcluding($userId, $path, $scannedIds);
541
542
		$count = \count($unscannedIds);
543
		if ($count) {
544
			$this->logger->info("Found $count unscanned music files for user $userId");
545
		} else {
546
			$this->logger->debug("No unscanned music files for user $userId");
547
		}
548
549
		return $unscannedIds;
550
	}
551
552
	/**
553
	 * Find already scanned music files which have been modified since the time they were scanned
554
	 *
555
	 * @return int[]
556
	 */
557
	public function getDirtyMusicFileIds(string $userId, ?string $path = null) : array {
558
		$fileIds = $this->trackBusinessLayer->findDirtyFileIds($userId);
559
560
		// filter by path if given
561
		if (!empty($path)) {
562
			try {
563
				$folder = $this->getMusicFolder($userId, $path);
564
			} catch (\OCP\Files\NotFoundException $e) {
565
				return [];
566
			}
567
			$fileIds = \array_filter($fileIds, fn(int $fileId) => (\count($folder->getById($fileId)) > 0));
568
		}
569
570
		return \array_values($fileIds); // make the array non-sparse
571
	}
572
573
	/**
574
	 * @return array ['count' => int, 'anlz_time' => int, 'db_time' => int], times in milliseconds
575
	 */
576
	public function scanFiles(string $userId, array $fileIds, ?OutputInterface $debugOutput = null) : array {
577
		$count = \count($fileIds);
578
		$this->logger->debug("Scanning $count files of user $userId");
579
580
		// back up the execution time limit
581
		$executionTime = \intval(\ini_get('max_execution_time'));
582
		// set execution time limit to unlimited
583
		\set_time_limit(0);
584
585
		$libraryRoot = $this->librarySettings->getFolder($userId);
586
587
		$count = 0;
588
		$totalAnalyzeTime = 0;
589
		$totalDbTime = 0;
590
		foreach ($fileIds as $fileId) {
591
			$this->cache->set($userId, 'scanning', (string)\time()); // update scanning status to prevent simultaneous background cleanup execution
592
593
			$file = $libraryRoot->getById($fileId)[0] ?? null;
594
			if ($file != null && !$this->librarySettings->pathBelongsToMusicLibrary($file->getPath(), $userId)) {
595
				$this->emit(self::class, 'exclude', [$file->getPath()]);
596
				$file = null;
597
			}
598
			if ($file instanceof File) {
599
				$memBefore = $debugOutput ? \memory_get_usage(true) : 0;
600
				list('analyze' => $analyzeTime, 'db update' => $dbTime)
601
					= $this->updateAudio($file, $userId, $libraryRoot, $file->getPath(), $file->getMimetype(), /*partOfScan=*/true);
602
				if ($debugOutput) {
603
					$memAfter = \memory_get_usage(true);
604
					$memDelta = $memAfter - $memBefore;
605
					$fmtMemAfter = Util::formatFileSize($memAfter);
606
					$fmtMemDelta = \mb_chr(0x0394) . Util::formatFileSize($memDelta);
607
					$path = $file->getPath();
608
					$fmtAnalyzeTime = 'anlz:' . (int)($analyzeTime / 1000000) . 'ms';
609
					$fmtDbTime = 'db:' . (int)($dbTime / 1000000) . 'ms';
610
					$debugOutput->writeln("\e[1m $count \e[0m $fmtMemAfter \e[1m ($fmtMemDelta) \e[0m $fmtAnalyzeTime \e[1m $fmtDbTime \e[0m $path");
611
				}
612
				$count++;
613
				$totalAnalyzeTime += $analyzeTime;
614
				$totalDbTime += $dbTime;
615
			} else {
616
				$this->logger->info("File with id $fileId not found for user $userId, removing it from the library if present");
617
				$this->deleteAudio([$fileId], [$userId]);
618
			}
619
		}
620
621
		// reset execution time limit
622
		\set_time_limit($executionTime);
623
624
		// invalidate the cache as the collection has changed and clear the 'scanning' status
625
		$this->cache->remove($userId, 'collection');
626
		$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
627
628
		return [
629
			'count' => $count,
630
			'anlz_time' => (int)($totalAnalyzeTime / 1000000),
631
			'db_time' => (int)($totalDbTime / 1000000)
632
		];
633
	}
634
635
	/**
636
	 * Check the availability of all the indexed audio files of the user. Remove
637
	 * from the index any which are not available.
638
	 * @return int Number of removed files
639
	 */
640
	public function removeUnavailableFiles(string $userId) : int {
641
		$indexedFiles = $this->getScannedFileIds($userId);
642
		$availableFiles = $this->getAllMusicFileIds($userId);
643
		$unavailableFiles = ArrayUtil::diff($indexedFiles, $availableFiles);
644
645
		$count = \count($unavailableFiles);
646
		if ($count > 0) {
647
			$this->logger->info('The following files are no longer available within the library of the '.
648
				"user $userId, removing: " . (string)\json_encode($unavailableFiles));
649
			$this->deleteAudio($unavailableFiles, [$userId]);
650
		}
651
		return $count;
652
	}
653
654
	/**
655
	 * Parse and get basic info about a file. The file does not have to be indexed in the database.
656
	 */
657
	public function getFileInfo(int $fileId, string $userId, Folder $userFolder) : ?array {
658
		$info = $this->getIndexedFileInfo($fileId, $userId, $userFolder)
659
			?: $this->getUnindexedFileInfo($fileId, $userId, $userFolder);
660
661
		// base64-encode and wrap the cover image if available
662
		if ($info !== null && $info['cover'] !== null) {
663
			$mime = $info['cover']['mimetype'];
664
			$content = $info['cover']['content'];
665
			$info['cover'] = 'data:' . $mime. ';base64,' . \base64_encode($content);
666
		}
667
668
		return $info;
669
	}
670
671
	private function getIndexedFileInfo(int $fileId, string $userId, Folder $userFolder) : ?array {
672
		$track = $this->trackBusinessLayer->findByFileId($fileId, $userId);
673
		if ($track !== null) {
674
			$artist = $this->artistBusinessLayer->find($track->getArtistId(), $userId);
675
			$album = $this->albumBusinessLayer->find($track->getAlbumId(), $userId);
676
			return [
677
				'title'      => $track->getTitle(),
678
				'artist'     => $artist->getName(),
679
				'cover'      => $this->coverService->getCover($album, $userId, $userFolder),
680
				'in_library' => true
681
			];
682
		}
683
		return null;
684
	}
685
686
	private function getUnindexedFileInfo(int $fileId, string $userId, Folder $userFolder) : ?array {
687
		$file = $userFolder->getById($fileId)[0] ?? null;
688
		if ($file instanceof File) {
689
			$metadata = $this->extractMetadata($file, $userFolder, $file->getPath(), true);
690
			$cover = $metadata['picture'];
691
			if ($cover != null) {
692
				$cover = $this->coverService->scaleDownAndCrop([
693
					'mimetype' => $cover['image_mime'],
694
					'content' => $cover['data']
695
				], 200);
696
			}
697
			return [
698
				'title'      => $metadata['title'],
699
				'artist'     => $metadata['artist'],
700
				'cover'      => $cover,
701
				'in_library' => $this->librarySettings->pathBelongsToMusicLibrary($file->getPath(), $userId)
702
			];
703
		}
704
		return null;
705
	}
706
707
	/**
708
	 * Update music path
709
	 */
710
	public function updatePath(string $oldPath, string $newPath, string $userId) : void {
711
		$this->logger->info("Changing music collection path of user $userId from $oldPath to $newPath");
712
713
		$userHome = $this->resolveUserFolder($userId);
714
715
		try {
716
			$oldFolder = FilesUtil::getFolderFromRelativePath($userHome, $oldPath);
717
			$newFolder = FilesUtil::getFolderFromRelativePath($userHome, $newPath);
718
719
			if ($newFolder->getPath() === $oldFolder->getPath()) {
720
				$this->logger->debug('New collection path is the same as the old path, nothing to do');
721
			} elseif ($newFolder->isSubNode($oldFolder)) {
722
				$this->logger->debug('New collection path is (grand) parent of old path, previous content is still valid');
723
			} elseif ($oldFolder->isSubNode($newFolder)) {
724
				$this->logger->debug('Old collection path is (grand) parent of new path, checking the validity of previous content');
725
				$this->removeUnavailableFiles($userId);
726
			} else {
727
				$this->logger->debug('Old and new collection paths are unrelated, erasing the previous collection content');
728
				$this->maintenance->resetLibrary($userId);
729
			}
730
		} catch (\OCP\Files\NotFoundException $e) {
731
			$this->logger->warning('One of the paths was invalid, erasing the previous collection content');
732
			$this->maintenance->resetLibrary($userId);
733
		}
734
	}
735
736
	/**
737
	 * Find external cover images for albums which do not yet have one.
738
	 * Target either one user or all users.
739
	 * @param string|null $userId
740
	 * @return bool true if any albums were updated; false otherwise
741
	 */
742
	public function findAlbumCovers(?string $userId = null) : bool {
743
		$affectedUsers = $this->albumBusinessLayer->findCovers($userId);
744
		// scratch the cache for those users whose music collection was touched
745
		foreach ($affectedUsers as $user) {
746
			$this->cache->remove($user, 'collection');
747
			$this->logger->debug('album cover(s) were found for user '. $user);
748
		}
749
		return !empty($affectedUsers);
750
	}
751
752
	/**
753
	 * Find external cover images for artists which do not yet have one.
754
	 * @param string $userId
755
	 * @return bool true if any albums were updated; false otherwise
756
	 */
757
	public function findArtistCovers(string $userId) : bool {
758
		$allImages = $this->getImageFiles($userId);
759
		return $this->artistBusinessLayer->updateCovers($allImages, $userId, $this->userL10N($userId));
760
	}
761
762
	public function resolveUserFolder(string $userId) : Folder {
763
		return $this->rootFolder->getUserFolder($userId);
764
	}
765
766
	/**
767
	 * Get the selected localization of the user, even in case there is no logged in user in the context.
768
	 */
769
	private function userL10N(string $userId) : IL10N {
770
		$languageCode = $this->config->getUserValue($userId, 'core', 'lang');
771
		return $this->l10nFactory->get('music', $languageCode);
772
	}
773
774
	/**
775
	 * @param int|float|string|null $ordinal
776
	 */
777
	private static function normalizeOrdinal(/*mixed*/ $ordinal) : ?int {
778
		if (\is_string($ordinal)) {
779
			// convert format '1/10' to '1'
780
			$ordinal = \explode('/', $ordinal)[0];
781
		}
782
783
		// check for numeric values - cast them to int and verify it's a natural number above 0
784
		if (\is_numeric($ordinal) && ((int)$ordinal) > 0) {
785
			$ordinal = (int)Util::limit((int)$ordinal, 0, Util::SINT32_MAX); // can't use UINT32_MAX since PostgreSQL has no unsigned types
786
		} else {
787
			$ordinal = null;
788
		}
789
790
		return $ordinal;
791
	}
792
793
	private static function parseFileName(string $fileName) : array {
794
		$matches = null;
795
		// If the file name starts e.g like "12. something" or "12 - something", the
796
		// preceding number is extracted as track number. Everything after the optional
797
		// track number + delimiters part but before the file extension is extracted as title.
798
		// The file extension consists of a '.' followed by 1-4 "word characters".
799
		if (\preg_match('/^((\d+)\s*[.-]\s+)?(.+)\.(\w{1,4})$/', $fileName, $matches) === 1) {
800
			return ['track_number' => $matches[2], 'title' => $matches[3]];
801
		} else {
802
			return ['track_number' => null, 'title' => $fileName];
803
		}
804
	}
805
806
	/**
807
	 * @param int|float|string|null $date
808
	 */
809
	private static function normalizeYear(/*mixed*/ $date) : ?int {
810
		$year = null;
811
		$matches = null;
812
813
		if (\is_numeric($date)) {
814
			$year = (int)$date; // the date is a valid year as-is
815
		} elseif (\is_string($date) && \preg_match('/^(\d\d\d\d)-\d\d-\d\d.*/', $date, $matches) === 1) {
816
			$year = (int)$matches[1]; // year from ISO-formatted date yyyy-mm-dd
817
		} else {
818
			$year = null;
819
		}
820
821
		return ($year === null) ? null : (int)Util::limit($year, Util::SINT32_MIN, Util::SINT32_MAX);
822
	}
823
824
	/**
825
	 * @param int|float|string|null $value
826
	 */
827
	private static function normalizeUnsigned(/*mixed*/ $value) : ?int {
828
		if (\is_numeric($value)) {
829
			$value = (int)\round((float)$value);
830
			$value = (int)Util::limit($value, 0, Util::SINT32_MAX); // can't use UINT32_MAX since PostgreSQL has no unsigned types
831
		} else {
832
			$value = null;
833
		}
834
		return $value;
835
	}
836
837
	/**
838
	 * Loop through the tracks of an album and set the first track containing embedded cover art
839
	 * as cover file for the album
840
	 * @param int $albumId
841
	 * @param string|null $userId name of user, deducted from $albumId if omitted
842
	 * @param Folder|null $baseFolder base folder for the search, library root of $userId is used if omitted
843
	 */
844
	private function findEmbeddedCoverForAlbum(int $albumId, ?string $userId=null, ?Folder $baseFolder=null) : void {
845
		if ($userId === null) {
846
			$userId = $this->albumBusinessLayer->findAlbumOwner($albumId);
847
		}
848
		if ($baseFolder === null) {
849
			$baseFolder = $this->librarySettings->getFolder($userId);
850
		}
851
852
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $userId);
853
		foreach ($tracks as $track) {
854
			$file = $baseFolder->getById($track->getFileId())[0] ?? null;
855
			if ($file instanceof File) {
856
				$image = $this->extractor->parseEmbeddedCoverArt($file);
857
				if ($image != null) {
858
					$this->albumBusinessLayer->setCover($track->getFileId(), $albumId);
859
					break;
860
				}
861
			}
862
		}
863
	}
864
}
865