Passed
Push — master ( de702b...3e9331 )
by Pauli
02:18
created

Scanner::deleteAudio()   A

Complexity

Conditions 4
Paths 2

Size

Total Lines 28
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 20

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 16
nc 2
nop 2
dl 0
loc 28
ccs 0
cts 17
cp 0
crap 20
rs 9.7333
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
		if ($artistId = $this->artistBusinessLayer->updateCover($file, $userId)) {
137
			$this->logger->log("updateImage - the image was set as cover for the artist $artistId", 'debug');
138
			$this->coverHelper->removeArtistCoverFromCache($artistId, $userId);
0 ignored issues
show
Bug introduced by
$artistId of type OCA\Music\BusinessLayer\artistId is incompatible with the type integer expected by parameter $artistId of OCA\Music\Utility\CoverH...eArtistCoverFromCache(). ( Ignorable by Annotation )

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

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