Passed
Push — master ( f358a5...b5f949 )
by Pauli
03:17
created

Scanner::normalizeUnsigned()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

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