Passed
Push — master ( 87f5e7...2a0488 )
by Pauli
01:54
created

Scanner::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 28
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

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