Passed
Push — master ( 51f739...732353 )
by Pauli
02:31
created

Scanner::extractMetadata()   C

Complexity

Conditions 11
Paths 288

Size

Total Lines 75
Code Lines 41

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 41
c 2
b 0
f 0
dl 0
loc 75
rs 5.3833
cc 11
nc 288
nop 4

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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