Passed
Push — master ( 7b8fea...bb5769 )
by Pauli
10:49
created

Scanner::isNonEmptyString()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 1
c 1
b 0
f 0
nc 2
nop 1
dl 0
loc 2
rs 10
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 - 2020
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
		// Trying to enable stream support
74
		if (\ini_get('allow_url_fopen') !== '1') {
75
			$this->logger->log('allow_url_fopen is disabled. It is strongly advised to enable it in your php.ini', 'warn');
76
			@\ini_set('allow_url_fopen', '1');
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for ini_set(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

76
			/** @scrutinizer ignore-unhandled */ @\ini_set('allow_url_fopen', '1');

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

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