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

Scanner::getAllMusicFileIds()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 2
nc 1
nop 1
dl 0
loc 3
ccs 0
cts 3
cp 0
crap 2
rs 10
c 1
b 0
f 0
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