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

Scanner::__construct()   A

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