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

Scanner::getUserMusicFolder()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 2
nc 1
nop 2
dl 0
loc 3
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 - 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