Scanner::__construct()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 30
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

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