Passed
Push — master ( 64a75f...2a0431 )
by Pauli
03:01
created

Scanner::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 28
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 14
c 2
b 0
f 0
dl 0
loc 28
rs 9.7998
cc 1
nc 1
nop 14

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