Passed
Push — master ( 8fb3d8...e6f5c5 )
by Pauli
12:47
created

Scanner::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 24
Code Lines 12

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 12
nc 1
nop 12
dl 0
loc 24
rs 9.8666
c 0
b 0
f 0

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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