Passed
Push — master ( 8fb3d8...e6f5c5 )
by Pauli
12:47
created

Scanner::removeUnavailableFiles()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 12
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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