Passed
Push — master ( f358a5...b5f949 )
by Pauli
03:17
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
		$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