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
	public 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, ?string $path = null) : array {
484
		try {
485
			$folderId = $this->pathInLibToFolderId($userId, $path);
486
		} catch (\OCP\Files\NotFoundException $e) {
487
			return [];
488
		}
489
		return $this->trackBusinessLayer->findAllFileIds($userId, $folderId);
490
	}
491
492
	/**
493
	 * Find already scanned music files which have been modified since the time they were scanned
494
	 *
495
	 * @return int[]
496
	 */
497
	public function getDirtyMusicFileIds(string $userId, ?string $path = null) : array {
498
		try {
499
			$folderId = $this->pathInLibToFolderId($userId, $path);
500
		} catch (\OCP\Files\NotFoundException $e) {
501
			return [];
502
		}
503
		return $this->trackBusinessLayer->findDirtyFileIds($userId, $folderId);
504
	}
505
506
	/**
507
	 * Convert given path to a folder ID, provided that the path is within the music library.
508
	 * The result is null if the $path points to th root of the music library. The $path null
509
	 * is considered to point to the root of the lib (like in getMusicFolder).
510
	 */
511
	private function pathInLibToFolderId(string $userId, ?string $path = null) : ?int {
512
		$folderId = null;
513
		if (!empty($path)) {
514
			$folderId = $this->getMusicFolder($userId, $path)->getId();
515
			if ($folderId == $this->getMusicFolder($userId, null)->getId()) {
516
				// the path just pointed to the root of the library so it doesn't actually limit anything
517
				$folderId = null;
518
			}
519
		}
520
		return $folderId;
521
	}
522
523
	private function getMusicFolder(string $userId, ?string $path) : Folder {
524
		$folder = $this->librarySettings->getFolder($userId);
525
526
		if (!empty($path)) {
527
			$userFolder = $this->resolveUserFolder($userId);
528
			$requestedFolder = FilesUtil::getFolderFromRelativePath($userFolder, $path);
529
			if ($folder->isSubNode($requestedFolder) || $folder->getPath() == $requestedFolder->getPath()) {
530
				$folder = $requestedFolder;
531
			} else {
532
				throw new \OCP\Files\NotFoundException();
533
			}
534
		}
535
536
		return $folder;
537
	}
538
539
	/**
540
	 * Search for music files by mimetype inside user specified library path
541
	 * (which defaults to user home dir).
542
	 * Optionally, limit the search to only the specified path. If this path doesn't
543
	 * point within the library path, then nothing will be found.
544
	 *
545
	 * @return int[]
546
	 */
547
	public function getAllMusicFileIds(string $userId, ?string $path = null) : array {
548
		try {
549
			$folder = $this->getMusicFolder($userId, $path);
550
		} catch (\OCP\Files\NotFoundException $e) {
551
			return [];
552
		}
553
554
		// Search files with mime 'audio/*' but filter out the playlist files and files under excluded folders
555
		$files = $folder->searchByMime('audio');
556
557
		$files = \array_filter($files, fn($f) =>
558
					!self::isPlaylistMime($f->getMimeType())
559
					&& $this->librarySettings->pathBelongsToMusicLibrary($f->getPath(), $userId)
560
		);
561
562
		return \array_values(ArrayUtil::extractIds($files)); // the array may be sparse before array_values
563
	}
564
565
	/**
566
	 * @return array{unscannedFiles: int[], obsoleteFiles: int[], dirtyFiles: int[], scannedCount: int}
567
	 */
568
	public function getStatusOfLibraryFiles(string $userId, ?string $path = null) : array {
569
		$scannedIds = $this->getScannedFileIds($userId, $path);
570
		$availableIds = $this->getAllMusicFileIds($userId, $path);
571
572
		return [
573
			'unscannedFiles' => ArrayUtil::diff($availableIds, $scannedIds),
574
			'obsoleteFiles' => ArrayUtil::diff($scannedIds, $availableIds),
575
			'dirtyFiles' => $this->getDirtyMusicFileIds($userId, $path),
576
			'scannedCount' => \count($scannedIds)
577
		];
578
	}
579
580
	/**
581
	 * @return array ['count' => int, 'anlz_time' => int, 'db_time' => int], times in milliseconds
582
	 */
583
	public function scanFiles(string $userId, array $fileIds, ?OutputInterface $debugOutput = null) : array {
584
		$count = \count($fileIds);
585
		$this->logger->debug("Scanning $count files of user $userId");
586
587
		// back up the execution time limit
588
		$executionTime = \intval(\ini_get('max_execution_time'));
589
		// set execution time limit to unlimited
590
		\set_time_limit(0);
591
592
		$libraryRoot = $this->librarySettings->getFolder($userId);
593
594
		$count = 0;
595
		$totalAnalyzeTime = 0;
596
		$totalDbTime = 0;
597
		foreach ($fileIds as $fileId) {
598
			$this->cache->set($userId, 'scanning', (string)\time()); // update scanning status to prevent simultaneous background cleanup execution
599
600
			$file = $libraryRoot->getById($fileId)[0] ?? null;
601
			if ($file != null && !$this->librarySettings->pathBelongsToMusicLibrary($file->getPath(), $userId)) {
602
				$this->emit(self::class, 'exclude', [$file->getPath()]);
603
				$file = null;
604
			}
605
			if ($file instanceof File) {
606
				$memBefore = $debugOutput ? \memory_get_usage(true) : 0;
607
				list('analyze' => $analyzeTime, 'db update' => $dbTime)
608
					= $this->updateAudio($file, $userId, $libraryRoot, $file->getPath(), $file->getMimetype(), /*partOfScan=*/true);
609
				if ($debugOutput) {
610
					$memAfter = \memory_get_usage(true);
611
					$memDelta = $memAfter - $memBefore;
612
					$fmtMemAfter = Util::formatFileSize($memAfter);
613
					$fmtMemDelta = \mb_chr(0x0394) . Util::formatFileSize($memDelta);
614
					$path = $file->getPath();
615
					$fmtAnalyzeTime = 'anlz:' . (int)($analyzeTime / 1000000) . 'ms';
616
					$fmtDbTime = 'db:' . (int)($dbTime / 1000000) . 'ms';
617
					$debugOutput->writeln("\e[1m $count \e[0m $fmtMemAfter \e[1m ($fmtMemDelta) \e[0m $fmtAnalyzeTime \e[1m $fmtDbTime \e[0m $path");
618
				}
619
				$count++;
620
				$totalAnalyzeTime += $analyzeTime;
621
				$totalDbTime += $dbTime;
622
			} else {
623
				$this->logger->info("File with id $fileId not found for user $userId, removing it from the library if present");
624
				$this->deleteAudio([$fileId], [$userId]);
625
			}
626
		}
627
628
		// reset execution time limit
629
		\set_time_limit($executionTime);
630
631
		// invalidate the cache as the collection has changed and clear the 'scanning' status
632
		$this->cache->remove($userId, 'collection');
633
		$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
634
635
		return [
636
			'count' => $count,
637
			'anlz_time' => (int)($totalAnalyzeTime / 1000000),
638
			'db_time' => (int)($totalDbTime / 1000000)
639
		];
640
	}
641
642
	/**
643
	 * Check the availability of all the indexed audio files of the user. Remove
644
	 * from the index any which are not available.
645
	 * @return int Number of removed files
646
	 */
647
	public function removeUnavailableFiles(string $userId) : int {
648
		$indexedFiles = $this->getScannedFileIds($userId);
649
		$availableFiles = $this->getAllMusicFileIds($userId);
650
		$unavailableFiles = ArrayUtil::diff($indexedFiles, $availableFiles);
651
652
		$count = \count($unavailableFiles);
653
		if ($count > 0) {
654
			$this->logger->info('The following files are no longer available within the library of the '.
655
				"user $userId, removing: " . (string)\json_encode($unavailableFiles));
656
			$this->deleteAudio($unavailableFiles, [$userId]);
657
		}
658
		return $count;
659
	}
660
661
	/**
662
	 * Parse and get basic info about a file. The file does not have to be indexed in the database.
663
	 */
664
	public function getFileInfo(int $fileId, string $userId, Folder $userFolder) : ?array {
665
		$info = $this->getIndexedFileInfo($fileId, $userId, $userFolder)
666
			?: $this->getUnindexedFileInfo($fileId, $userId, $userFolder);
667
668
		// base64-encode and wrap the cover image if available
669
		if ($info !== null && $info['cover'] !== null) {
670
			$mime = $info['cover']['mimetype'];
671
			$content = $info['cover']['content'];
672
			$info['cover'] = 'data:' . $mime. ';base64,' . \base64_encode($content);
673
		}
674
675
		return $info;
676
	}
677
678
	private function getIndexedFileInfo(int $fileId, string $userId, Folder $userFolder) : ?array {
679
		$track = $this->trackBusinessLayer->findByFileId($fileId, $userId);
680
		if ($track !== null) {
681
			$artist = $this->artistBusinessLayer->find($track->getArtistId(), $userId);
682
			$album = $this->albumBusinessLayer->find($track->getAlbumId(), $userId);
683
			return [
684
				'title'      => $track->getTitle(),
685
				'artist'     => $artist->getName(),
686
				'cover'      => $this->coverService->getCover($album, $userId, $userFolder),
687
				'in_library' => true
688
			];
689
		}
690
		return null;
691
	}
692
693
	private function getUnindexedFileInfo(int $fileId, string $userId, Folder $userFolder) : ?array {
694
		$file = $userFolder->getById($fileId)[0] ?? null;
695
		if ($file instanceof File) {
696
			$metadata = $this->extractMetadata($file, $userFolder, $file->getPath(), true);
697
			$cover = $metadata['picture'];
698
			if ($cover != null) {
699
				$cover = $this->coverService->scaleDownAndCrop([
700
					'mimetype' => $cover['image_mime'],
701
					'content' => $cover['data']
702
				], 200);
703
			}
704
			return [
705
				'title'      => $metadata['title'],
706
				'artist'     => $metadata['artist'],
707
				'cover'      => $cover,
708
				'in_library' => $this->librarySettings->pathBelongsToMusicLibrary($file->getPath(), $userId)
709
			];
710
		}
711
		return null;
712
	}
713
714
	/**
715
	 * Update music path
716
	 */
717
	public function updatePath(string $oldPath, string $newPath, string $userId) : void {
718
		$this->logger->info("Changing music collection path of user $userId from $oldPath to $newPath");
719
720
		$userHome = $this->resolveUserFolder($userId);
721
722
		try {
723
			$oldFolder = FilesUtil::getFolderFromRelativePath($userHome, $oldPath);
724
			$newFolder = FilesUtil::getFolderFromRelativePath($userHome, $newPath);
725
726
			if ($newFolder->getPath() === $oldFolder->getPath()) {
727
				$this->logger->debug('New collection path is the same as the old path, nothing to do');
728
			} elseif ($newFolder->isSubNode($oldFolder)) {
729
				$this->logger->debug('New collection path is (grand) parent of old path, previous content is still valid');
730
			} elseif ($oldFolder->isSubNode($newFolder)) {
731
				$this->logger->debug('Old collection path is (grand) parent of new path, checking the validity of previous content');
732
				$this->removeUnavailableFiles($userId);
733
			} else {
734
				$this->logger->debug('Old and new collection paths are unrelated, erasing the previous collection content');
735
				$this->maintenance->resetLibrary($userId);
736
			}
737
		} catch (\OCP\Files\NotFoundException $e) {
738
			$this->logger->warning('One of the paths was invalid, erasing the previous collection content');
739
			$this->maintenance->resetLibrary($userId);
740
		}
741
	}
742
743
	/**
744
	 * Find external cover images for albums which do not yet have one.
745
	 * Target either one user or all users. 
746
	 * Optionally, limit the search to only the specified path. If this path doesn't point within the library path,
747
	 * then nothing will be found. Path is not supported when all users are targeted.
748
	 * 
749
	 * @return bool true if any albums were updated; false otherwise
750
	 */
751
	public function findAlbumCovers(?string $userId = null, ?string $path = null) : bool {
752
		$folderId = null;
753
		if ($path !== null) {
754
			if ($userId === null) {
755
				throw new \InvalidArgumentException('Argument $path is not supported without argument $userId');
756
			}
757
			try {
758
				$folderId = $this->pathInLibToFolderId($userId, $path);
759
			} catch (\OCP\Files\NotFoundException $e) {
760
				return false;
761
			}
762
		}
763
764
		$affectedUsers = $this->albumBusinessLayer->findCovers($userId, $folderId);
765
		// scratch the cache for those users whose music collection was touched
766
		foreach ($affectedUsers as $user) {
767
			$this->cache->remove($user, 'collection');
768
			$this->logger->debug('album cover(s) were found for user '. $user);
769
		}
770
		return !empty($affectedUsers);
771
	}
772
773
	/**
774
	 * Find external cover images for artists which do not yet have one.
775
	 * @param string $userId
776
	 * @return bool true if any albums were updated; false otherwise
777
	 */
778
	public function findArtistCovers(string $userId) : bool {
779
		$allImages = $this->getImageFiles($userId);
780
		return $this->artistBusinessLayer->updateCovers($allImages, $userId, $this->userL10N($userId));
781
	}
782
783
	public function resolveUserFolder(string $userId) : Folder {
784
		return $this->rootFolder->getUserFolder($userId);
785
	}
786
787
	/**
788
	 * Get the selected localization of the user, even in case there is no logged in user in the context.
789
	 */
790
	private function userL10N(string $userId) : IL10N {
791
		$languageCode = $this->config->getUserValue($userId, 'core', 'lang');
792
		return $this->l10nFactory->get('music', $languageCode);
793
	}
794
795
	/**
796
	 * @param int|float|string|null $ordinal
797
	 */
798
	private static function normalizeOrdinal(/*mixed*/ $ordinal) : ?int {
799
		if (\is_string($ordinal)) {
800
			// convert format '1/10' to '1'
801
			$ordinal = \explode('/', $ordinal)[0];
802
		}
803
804
		// check for numeric values - cast them to int and verify it's a natural number above 0
805
		if (\is_numeric($ordinal) && ((int)$ordinal) > 0) {
806
			$ordinal = (int)Util::limit((int)$ordinal, 0, Util::SINT32_MAX); // can't use UINT32_MAX since PostgreSQL has no unsigned types
807
		} else {
808
			$ordinal = null;
809
		}
810
811
		return $ordinal;
812
	}
813
814
	private static function parseFileName(string $fileName) : array {
815
		$matches = null;
816
		// If the file name starts e.g like "12. something" or "12 - something", the
817
		// preceding number is extracted as track number. Everything after the optional
818
		// track number + delimiters part but before the file extension is extracted as title.
819
		// The file extension consists of a '.' followed by 1-4 "word characters".
820
		if (\preg_match('/^((\d+)\s*[.-]\s+)?(.+)\.(\w{1,4})$/', $fileName, $matches) === 1) {
821
			return ['track_number' => $matches[2], 'title' => $matches[3]];
822
		} else {
823
			return ['track_number' => null, 'title' => $fileName];
824
		}
825
	}
826
827
	/**
828
	 * @param int|float|string|null $date
829
	 */
830
	private static function normalizeYear(/*mixed*/ $date) : ?int {
831
		$year = null;
832
		$matches = null;
833
834
		if (\is_numeric($date)) {
835
			$year = (int)$date; // the date is a valid year as-is
836
		} elseif (\is_string($date) && \preg_match('/^(\d\d\d\d)-\d\d-\d\d.*/', $date, $matches) === 1) {
837
			$year = (int)$matches[1]; // year from ISO-formatted date yyyy-mm-dd
838
		} else {
839
			$year = null;
840
		}
841
842
		return ($year === null) ? null : (int)Util::limit($year, Util::SINT32_MIN, Util::SINT32_MAX);
843
	}
844
845
	/**
846
	 * @param int|float|string|null $value
847
	 */
848
	private static function normalizeUnsigned(/*mixed*/ $value) : ?int {
849
		if (\is_numeric($value)) {
850
			$value = (int)\round((float)$value);
851
			$value = (int)Util::limit($value, 0, Util::SINT32_MAX); // can't use UINT32_MAX since PostgreSQL has no unsigned types
852
		} else {
853
			$value = null;
854
		}
855
		return $value;
856
	}
857
858
	/**
859
	 * Loop through the tracks of an album and set the first track containing embedded cover art
860
	 * as cover file for the album
861
	 * @param int $albumId
862
	 * @param string|null $userId name of user, deducted from $albumId if omitted
863
	 * @param Folder|null $baseFolder base folder for the search, library root of $userId is used if omitted
864
	 */
865
	private function findEmbeddedCoverForAlbum(int $albumId, ?string $userId=null, ?Folder $baseFolder=null) : void {
866
		if ($userId === null) {
867
			$userId = $this->albumBusinessLayer->findAlbumOwner($albumId);
868
		}
869
		if ($baseFolder === null) {
870
			$baseFolder = $this->librarySettings->getFolder($userId);
871
		}
872
873
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $userId);
874
		foreach ($tracks as $track) {
875
			$file = $baseFolder->getById($track->getFileId())[0] ?? null;
876
			if ($file instanceof File) {
877
				$image = $this->extractor->parseEmbeddedCoverArt($file);
878
				if ($image != null) {
879
					$this->albumBusinessLayer->setCover($track->getFileId(), $albumId);
880
					break;
881
				}
882
			}
883
		}
884
	}
885
}
886