Passed
Push — master ( 51f739...732353 )
by Pauli
02:31
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 - 2021
13
 */
14
15
namespace OCA\Music\Utility;
16
17
use OC\Hooks\PublicEmitter;
0 ignored issues
show
Bug introduced by
The type OC\Hooks\PublicEmitter was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
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, Folder $userHome, 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
			$this->updateAudio($file, $userId, $userHome, $filePath, $mimetype, /*partOfScan=*/false);
104
		}
105
	}
106
107
	private static function isPlaylistMime(string $mime) : bool {
108
		return $mime == 'audio/mpegurl' || $mime == 'audio/x-scpls';
109
	}
110
111
	private function updateImage(File $file, string $userId) : void {
112
		$coverFileId = $file->getId();
113
		$parentFolderId = $file->getParent()->getId();
114
		if ($this->albumBusinessLayer->updateFolderCover($coverFileId, $parentFolderId)) {
115
			$this->logger->log('updateImage - the image was set as cover for some album(s)', 'debug');
116
			$this->cache->remove($userId, 'collection');
117
		}
118
119
		$artistIds = $this->artistBusinessLayer->updateCover($file, $userId, $this->userL10N($userId));
120
		foreach ($artistIds as $artistId) {
121
			$this->logger->log("updateImage - the image was set as cover for the artist $artistId", 'debug');
122
			$this->coverHelper->removeArtistCoverFromCache($artistId, $userId);
123
		}
124
	}
125
126
	private function updateAudio(File $file, string $userId, Folder $userHome, string $filePath, string $mimetype, bool $partOfScan) : void {
127
		$this->emit('\OCA\Music\Utility\Scanner', 'update', [$filePath]);
128
129
		$analysisEnabled = $this->librarySettings->getScanMetadataEnabled($userId);
130
		$meta = $this->extractMetadata($file, $userHome, $filePath, $analysisEnabled);
131
		$fileId = $file->getId();
132
133
		// add/update artist and get artist entity
134
		$artist = $this->artistBusinessLayer->addOrUpdateArtist($meta['artist'], $userId);
135
		$artistId = $artist->getId();
136
137
		// add/update albumArtist and get artist entity
138
		$albumArtist = $this->artistBusinessLayer->addOrUpdateArtist($meta['albumArtist'], $userId);
139
		$albumArtistId = $albumArtist->getId();
140
141
		// add/update album and get album entity
142
		$album = $this->albumBusinessLayer->addOrUpdateAlbum($meta['album'], $albumArtistId, $userId);
143
		$albumId = $album->getId();
144
145
		// add/update genre and get genre entity
146
		$genre = $this->genreBusinessLayer->addOrUpdateGenre($meta['genre'], $userId);
147
148
		// add/update track and get track entity
149
		$track = $this->trackBusinessLayer->addOrUpdateTrack(
150
				$meta['title'], $meta['trackNumber'], $meta['discNumber'], $meta['year'], $genre->getId(),
151
				$artistId, $albumId, $fileId, $mimetype, $userId, $meta['length'], $meta['bitrate']);
152
153
		// if present, use the embedded album art as cover for the respective album
154
		if ($meta['picture'] != null) {
155
			// during scanning, don't repeatedly change the file providing the art for the album
156
			if ($album->getCoverFileId() === null || !$partOfScan) {
157
				$this->albumBusinessLayer->setCover($fileId, $albumId);
158
				$this->coverHelper->removeAlbumCoverFromCache($albumId, $userId);
159
			}
160
		}
161
		// if this file is an existing file which previously was used as cover for an album but now
162
		// the file no longer contains any embedded album art
163
		elseif ($album->getCoverFileId() === $fileId) {
164
			$this->albumBusinessLayer->removeCovers([$fileId]);
165
			$this->findEmbeddedCoverForAlbum($albumId, $userId, $userHome);
166
			$this->coverHelper->removeAlbumCoverFromCache($albumId, $userId);
167
		}
168
169
		if (!$partOfScan) {
170
			// invalidate the cache as the music collection was changed
171
			$this->cache->remove($userId, 'collection');
172
		}
173
174
		// debug logging
175
		$this->logger->log('imported entities - ' .
176
				"artist: $artistId, albumArtist: $albumArtistId, album: $albumId, track: {$track->getId()}",
177
				'debug');
178
	}
179
180
	private function extractMetadata(File $file, Folder $userHome, string $filePath, bool $analyzeFile) : array {
181
		$fieldsFromFileName = self::parseFileName($file->getName());
182
		$fileInfo = $analyzeFile ? $this->extractor->extract($file) : [];
183
		$meta = [];
184
185
		// Track artist and album artist
186
		$meta['artist'] = ExtractorGetID3::getTag($fileInfo, 'artist');
187
		$meta['albumArtist'] = ExtractorGetID3::getFirstOfTags($fileInfo, ['band', 'albumartist', 'album artist', 'album_artist']);
188
189
		// use artist and albumArtist as fallbacks for each other
190
		if (!Util::isNonEmptyString($meta['albumArtist'])) {
191
			$meta['albumArtist'] = $meta['artist'];
192
		}
193
194
		if (!Util::isNonEmptyString($meta['artist'])) {
195
			$meta['artist'] = $meta['albumArtist'];
196
		}
197
198
		if (!Util::isNonEmptyString($meta['artist'])) {
199
			// neither artist nor albumArtist set in fileinfo, use the second level parent folder name
200
			// unless it is the user root folder
201
			$dirPath = \dirname(\dirname($filePath));
202
			if (Util::startsWith($userHome->getPath(), $dirPath)) {
203
				$artistName = null;
204
			} else {
205
				$artistName = \basename($dirPath);
206
			}
207
208
			$meta['artist'] = $artistName;
209
			$meta['albumArtist'] = $artistName;
210
		}
211
212
		// title
213
		$meta['title'] = ExtractorGetID3::getTag($fileInfo, 'title');
214
		if (!Util::isNonEmptyString($meta['title'])) {
215
			$meta['title'] = $fieldsFromFileName['title'];
216
		}
217
218
		// album
219
		$meta['album'] = ExtractorGetID3::getTag($fileInfo, 'album');
220
		if (!Util::isNonEmptyString($meta['album'])) {
221
			// album name not set in fileinfo, use parent folder name as album name unless it is the root folder
222
			$dirPath = \dirname($filePath);
223
			if ($userHome->getPath() === $dirPath) {
224
				$meta['album'] = null;
225
			} else {
226
				$meta['album'] = \basename($dirPath);
227
			}
228
		}
229
230
		// track number
231
		$meta['trackNumber'] = ExtractorGetID3::getFirstOfTags($fileInfo, ['track_number', 'tracknumber', 'track'],
232
				$fieldsFromFileName['track_number']);
233
		$meta['trackNumber'] = self::normalizeOrdinal($meta['trackNumber']);
234
235
		// disc number
236
		$meta['discNumber'] = ExtractorGetID3::getFirstOfTags($fileInfo, ['disc_number', 'discnumber', 'part_of_a_set'], '1');
237
		$meta['discNumber'] = self::normalizeOrdinal($meta['discNumber']);
238
239
		// year
240
		$meta['year'] = ExtractorGetID3::getFirstOfTags($fileInfo, ['year', 'date', 'creation_date']);
241
		$meta['year'] = self::normalizeYear($meta['year']);
242
243
		$meta['genre'] = ExtractorGetID3::getTag($fileInfo, 'genre') ?: ''; // empty string used for "scanned but unknown"
244
245
		$meta['picture'] = ExtractorGetID3::getTag($fileInfo, 'picture', true);
246
247
		$meta['length'] = $fileInfo['playtime_seconds'] ?? null;
248
		if ($meta['length'] !== null) {
249
			$meta['length'] = \round($meta['length']);
250
		}
251
252
		$meta['bitrate'] = $fileInfo['audio']['bitrate'] ?? null;
253
254
		return $meta;
255
	}
256
257
	/**
258
	 * @param string[] $affectedUsers
259
	 * @param int[] $affectedAlbums
260
	 * @param int[] $affectedArtists
261
	 */
262
	private function invalidateCacheOnDelete(array $affectedUsers, array $affectedAlbums, array $affectedArtists) : void {
263
		// Delete may be for one file or for a folder containing thousands of albums.
264
		// If loads of albums got affected, then ditch the whole cache of the affected
265
		// users because removing the cached covers one-by-one could delay the delete
266
		// operation significantly.
267
		$albumCount = \count($affectedAlbums);
268
		$artistCount = \count($affectedArtists);
269
		$userCount = \count($affectedUsers);
270
271
		if ($albumCount + $artistCount > 100) {
272
			$this->logger->log("Delete operation affected $albumCount albums and $artistCount artists. " .
273
								"Invalidate the whole cache of all affected users ($userCount).", 'debug');
274
			foreach ($affectedUsers as $user) {
275
				$this->cache->remove($user);
276
			}
277
		} else {
278
			// remove the cached covers
279
			if ($artistCount > 0) {
280
				$this->logger->log("Remove covers of $artistCount artist(s) from the cache (if present)", 'debug');
281
				foreach ($affectedArtists as $artistId) {
282
					$this->coverHelper->removeArtistCoverFromCache($artistId);
283
				}
284
			}
285
286
			if ($albumCount > 0) {
287
				$this->logger->log("Remove covers of $albumCount album(s) from the cache (if present)", 'debug');
288
				foreach ($affectedAlbums as $albumId) {
289
					$this->coverHelper->removeAlbumCoverFromCache($albumId);
290
				}
291
			}
292
293
			// remove the cached collection regardless of if covers were affected; it may be that this
294
			// function got called after a track was deleted and there are no album/artist changes
295
			foreach ($affectedUsers as $user) {
296
				$this->cache->remove($user, 'collection');
297
			}
298
		}
299
	}
300
301
	/**
302
	 * @param int[] $fileIds
303
	 * @param string[]|null $userIds
304
	 * @return boolean true if anything was removed
305
	 */
306
	private function deleteAudio(array $fileIds, array $userIds=null) : bool {
307
		$this->logger->log('deleteAudio - '. \implode(', ', $fileIds), 'debug');
308
		$this->emit('\OCA\Music\Utility\Scanner', 'delete', [$fileIds, $userIds]);
309
310
		$result = $this->trackBusinessLayer->deleteTracks($fileIds, $userIds);
311
312
		if ($result) { // one or more tracks were removed
313
			// remove obsolete artists and albums, and track references in playlists
314
			$this->albumBusinessLayer->deleteById($result['obsoleteAlbums']);
315
			$this->artistBusinessLayer->deleteById($result['obsoleteArtists']);
316
			$this->playlistBusinessLayer->removeTracksFromAllLists($result['deletedTracks']);
317
318
			// check if a removed track was used as embedded cover art file for a remaining album
319
			foreach ($result['remainingAlbums'] as $albumId) {
320
				if ($this->albumBusinessLayer->albumCoverIsOneOfFiles($albumId, $fileIds)) {
321
					$this->albumBusinessLayer->setCover(null, $albumId);
322
					$this->findEmbeddedCoverForAlbum($albumId);
323
					$this->coverHelper->removeAlbumCoverFromCache($albumId);
324
				}
325
			}
326
327
			$this->invalidateCacheOnDelete(
328
					$result['affectedUsers'], $result['obsoleteAlbums'], $result['obsoleteArtists']);
329
330
			$this->logger->log('removed entities - ' . \json_encode($result), 'debug');
331
		}
332
333
		return $result !== false;
334
	}
335
336
	/**
337
	 * @param int[] $fileIds
338
	 * @param string[]|null $userIds
339
	 * @return boolean true if anything was removed
340
	 */
341
	private function deleteImage(array $fileIds, array $userIds=null) : bool {
342
		$this->logger->log('deleteImage - '. \implode(', ', $fileIds), 'debug');
343
344
		$affectedAlbums = $this->albumBusinessLayer->removeCovers($fileIds, $userIds);
345
		$affectedArtists = $this->artistBusinessLayer->removeCovers($fileIds, $userIds);
346
347
		$affectedUsers = \array_merge(
348
			Util::extractUserIds($affectedAlbums),
349
			Util::extractUserIds($affectedArtists)
350
		);
351
		$affectedUsers = \array_unique($affectedUsers);
352
353
		$this->invalidateCacheOnDelete(
354
				$affectedUsers, Util::extractIds($affectedAlbums), Util::extractIds($affectedArtists));
355
356
		return (\count($affectedAlbums) + \count($affectedArtists) > 0);
357
	}
358
359
	/**
360
	 * Gets called by 'unshare' hook and 'delete' hook
361
	 *
362
	 * @param int $fileId ID of the deleted files
363
	 * @param string[]|null $userIds the IDs of the users to remove the file from; if omitted,
364
	 *                               the file is removed from all users (ie. owner and sharees)
365
	 */
366
	public function delete(int $fileId, array $userIds=null) : void {
367
		if (!$this->deleteAudio([$fileId], $userIds) && !$this->deleteImage([$fileId], $userIds)) {
368
			$this->logger->log("deleted file $fileId was not an indexed " .
369
					'audio file or a cover image', 'debug');
370
		}
371
	}
372
373
	/**
374
	 * Remove all audio files and cover images in the given folder from the database.
375
	 * This gets called when a folder is deleted or unshared from the user.
376
	 *
377
	 * @param Folder $folder
378
	 * @param string[]|null $userIds the IDs of the users to remove the folder from; if omitted,
379
	 *                               the folder is removed from all users (ie. owner and sharees)
380
	 */
381
	public function deleteFolder(Folder $folder, array $userIds=null) : void {
382
		$audioFiles = $folder->searchByMime('audio');
383
		if (\count($audioFiles) > 0) {
384
			$this->deleteAudio(Util::extractIds($audioFiles), $userIds);
385
		}
386
387
		// NOTE: When a folder is removed, we don't need to check for any image
388
		// files in the folder. This is because those images could be potentially
389
		// used as covers only on the audio files of the same folder and those
390
		// were already removed above.
391
	}
392
393
	/**
394
	 * search for image files by mimetype inside user specified library path
395
	 * (which defaults to user home dir)
396
	 *
397
	 * @return File[]
398
	 */
399
	private function getImageFiles(string $userId) : array {
400
		try {
401
			$folder = $this->librarySettings->getFolder($userId);
402
		} catch (\OCP\Files\NotFoundException $e) {
403
			return [];
404
		}
405
406
		$images = $folder->searchByMime('image');
407
408
		// filter out any images in the excluded folders
409
		return \array_filter($images, function ($image) use ($userId) {
410
			return $this->librarySettings->pathBelongsToMusicLibrary($image->getPath(), $userId);
411
		});
412
	}
413
414
	private function getScannedFileIds(string $userId) : array {
415
		return $this->trackBusinessLayer->findAllFileIds($userId);
416
	}
417
418
	private function getMusicFolder(string $userId, ?string $path) {
419
		$folder = $this->librarySettings->getFolder($userId);
420
421
		if (!empty($path)) {
422
			$userFolder = $this->resolveUserFolder($userId);
423
			$requestedFolder = Util::getFolderFromRelativePath($userFolder, $path);
424
			if ($folder->isSubNode($requestedFolder) || $folder->getPath() == $requestedFolder->getPath()) {
425
				$folder = $requestedFolder;
426
			} else {
427
				throw new \OCP\Files\NotFoundException();
428
			}
429
		}
430
431
		return $folder;
432
	}
433
434
	/**
435
	 * Search for music files by mimetype inside user specified library path
436
	 * (which defaults to user home dir). Exclude given array of IDs.
437
	 * Optionally, limit the search to only the specified path. If this path doesn't
438
	 * point within the library path, then nothing will be found.
439
	 *
440
	 * @param int[] $excludeIds
441
	 * @return int[]
442
	 */
443
	private function getAllMusicFileIdsExcluding(string $userId, ?string $path, array $excludeIds) : array {
444
		try {
445
			$folder = $this->getMusicFolder($userId, $path);
446
		} catch (\OCP\Files\NotFoundException $e) {
447
			return [];
448
		}
449
450
		// Search files with mime 'audio/*' but filter out the playlist files and files under excluded folders
451
		$files = $folder->searchByMime('audio');
452
453
		// Look-up-table of IDs to be excluded from the final result
454
		$excludeIdsLut = \array_flip($excludeIds);
455
456
		$files = \array_filter($files, function ($f) use ($userId, $excludeIdsLut) {
457
			return !isset($excludeIdsLut[$f->getId()])
458
					&& !self::isPlaylistMime($f->getMimeType())
459
					&& $this->librarySettings->pathBelongsToMusicLibrary($f->getPath(), $userId);
460
		});
461
462
		return \array_values(Util::extractIds($files)); // the array may be sparse before array_values
463
	}
464
465
	public function getAllMusicFileIds(string $userId, ?string $path = null) : array {
466
		return $this->getAllMusicFileIdsExcluding($userId, $path, []);
467
	}
468
469
	public function getUnscannedMusicFileIds(string $userId, ?string $path = null) : array {
470
		$scannedIds = $this->getScannedFileIds($userId);
471
		$unscannedIds = $this->getAllMusicFileIdsExcluding($userId, $path, $scannedIds);
472
473
		$count = \count($unscannedIds);
474
		if ($count) {
475
			$this->logger->log("Found $count unscanned music files for user $userId", 'info');
476
		} else {
477
			$this->logger->log("No unscanned music files for user $userId", 'debug');
478
		}
479
480
		return $unscannedIds;
481
	}
482
483
	/**
484
	 * Find already scanned music files which have been modified since the time they were scanned
485
	 *
486
	 * @return int[]
487
	 */
488
	public function getDirtyMusicFileIds(string $userId, ?string $path = null) : array {
489
		$tracks = $this->trackBusinessLayer->findAllDirty($userId);
490
		$fileIds = Util::arrayMapMethod($tracks, 'getFileId');
491
492
		// filter by path if given
493
		if (!empty($path)) {
494
			try {
495
				$folder = $this->getMusicFolder($userId, $path);
496
			} catch (\OCP\Files\NotFoundException $e) {
497
				return [];
498
			}
499
			$fileIds = \array_filter($fileIds, function(int $fileId) use ($folder) {
500
				return \count($folder->getById($fileId)) > 0;
501
			});
502
		}
503
504
		return \array_values($fileIds); // make the array non-sparse
505
	}
506
507
	public function scanFiles(string $userId, Folder $userHome, array $fileIds, OutputInterface $debugOutput = null) : int {
508
		$count = \count($fileIds);
509
		$this->logger->log("Scanning $count files of user $userId", 'debug');
510
511
		// back up the execution time limit
512
		$executionTime = \intval(\ini_get('max_execution_time'));
513
		// set execution time limit to unlimited
514
		\set_time_limit(0);
515
516
		$count = 0;
517
		foreach ($fileIds as $fileId) {
518
			$file = $userHome->getById($fileId)[0] ?? null;
519
			if ($file instanceof File) {
520
				$memBefore = $debugOutput ? \memory_get_usage(true) : 0;
521
				$this->updateAudio($file, $userId, $userHome, $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 = [
603
					'mimetype' => $cover['image_mime'],
604
					'content' => $this->coverHelper->scaleDownAndCrop($cover['data'], 200)
605
				];
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->resetDb($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->resetDb($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($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 $ordinal;
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($date) : ?int {
714
		$matches = null;
715
716
		if (\ctype_digit($date)) {
717
			return (int)$date; // the date is a valid year as-is
718
		} elseif (\is_string($date) && \preg_match('/^(\d\d\d\d)-\d\d-\d\d.*/', $date, $matches) === 1) {
719
			return (int)$matches[1]; // year from ISO-formatted date yyyy-mm-dd
720
		} else {
721
			return null;
722
		}
723
	}
724
725
	/**
726
	 * Loop through the tracks of an album and set the first track containing embedded cover art
727
	 * as cover file for the album
728
	 * @param int $albumId
729
	 * @param string|null $userId name of user, deducted from $albumId if omitted
730
	 * @param Folder|null $userFolder home folder of user, deducted from $userId if omitted
731
	 */
732
	private function findEmbeddedCoverForAlbum(int $albumId, string $userId=null, Folder $userFolder=null) : void {
733
		if ($userId === null) {
734
			$userId = $this->albumBusinessLayer->findAlbumOwner($albumId);
735
		}
736
		if ($userFolder === null) {
737
			$userFolder = $this->resolveUserFolder($userId);
738
		}
739
740
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $userId);
741
		foreach ($tracks as $track) {
742
			$nodes = $userFolder->getById($track->getFileId());
743
			if (\count($nodes) > 0) {
744
				// parse the first valid node and check if it contains embedded cover art
745
				$image = $this->extractor->parseEmbeddedCoverArt($nodes[0]);
746
				if ($image != null) {
747
					$this->albumBusinessLayer->setCover($track->getFileId(), $albumId);
748
					break;
749
				}
750
			}
751
		}
752
	}
753
}
754