Passed
Push — feature/329_Subsonic_API ( a9dee6...9d1353 )
by Pauli
14:33
created

Scanner::__construct()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 27
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 4
Bugs 0 Features 0
Metric Value
cc 2
eloc 14
nc 2
nop 11
dl 0
loc 27
rs 9.7998
c 4
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 - 2019
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\TrackBusinessLayer;
27
use \OCA\Music\BusinessLayer\PlaylistBusinessLayer;
28
use \OCA\Music\Db\Cache;
29
use \OCA\Music\Db\Maintenance;
30
31
use Symfony\Component\Console\Output\OutputInterface;
32
33
class Scanner extends PublicEmitter {
34
	private $extractor;
35
	private $artistBusinessLayer;
36
	private $albumBusinessLayer;
37
	private $trackBusinessLayer;
38
	private $playlistBusinessLayer;
39
	private $cache;
40
	private $coverHelper;
41
	private $logger;
42
	private $maintenance;
43
	private $userMusicFolder;
44
	private $rootFolder;
45
46
	public function __construct(Extractor $extractor,
47
								ArtistBusinessLayer $artistBusinessLayer,
48
								AlbumBusinessLayer $albumBusinessLayer,
49
								TrackBusinessLayer $trackBusinessLayer,
50
								PlaylistBusinessLayer $playlistBusinessLayer,
51
								Cache $cache,
52
								CoverHelper $coverHelper,
53
								Logger $logger,
54
								Maintenance $maintenance,
55
								UserMusicFolder $userMusicFolder,
56
								IRootFolder $rootFolder) {
57
		$this->extractor = $extractor;
58
		$this->artistBusinessLayer = $artistBusinessLayer;
59
		$this->albumBusinessLayer = $albumBusinessLayer;
60
		$this->trackBusinessLayer = $trackBusinessLayer;
61
		$this->playlistBusinessLayer = $playlistBusinessLayer;
62
		$this->cache = $cache;
63
		$this->coverHelper = $coverHelper;
64
		$this->logger = $logger;
65
		$this->maintenance = $maintenance;
66
		$this->userMusicFolder = $userMusicFolder;
67
		$this->rootFolder = $rootFolder;
68
69
		// Trying to enable stream support
70
		if (\ini_get('allow_url_fopen') !== '1') {
71
			$this->logger->log('allow_url_fopen is disabled. It is strongly advised to enable it in your php.ini', 'warn');
72
			@\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

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