Completed
Push — master ( 312fe7...029b8d )
by Pauli
11:52
created

Scanner::__construct()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 29
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

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