Scanner::invalidateCacheOnDelete()   B
last analyzed

Complexity

Conditions 8
Paths 10

Size

Total Lines 35
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 8
eloc 19
nc 10
nop 3
dl 0
loc 35
rs 8.4444
c 1
b 0
f 0
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 - 2025
13
 */
14
15
namespace OCA\Music\Service;
16
17
use OC\Hooks\PublicEmitter;
18
19
use OCP\Files\File;
20
use OCP\Files\Folder;
21
use OCP\Files\IRootFolder;
22
use OCP\IConfig;
23
use OCP\IL10N;
24
use OCP\L10N\IFactory;
25
26
use OCA\Music\AppFramework\Core\Logger;
27
use OCA\Music\BusinessLayer\ArtistBusinessLayer;
28
use OCA\Music\BusinessLayer\AlbumBusinessLayer;
29
use OCA\Music\BusinessLayer\GenreBusinessLayer;
30
use OCA\Music\BusinessLayer\PlaylistBusinessLayer;
31
use OCA\Music\BusinessLayer\TrackBusinessLayer;
32
use OCA\Music\Db\Cache;
33
use OCA\Music\Db\Maintenance;
34
use OCA\Music\Utility\ArrayUtil;
35
use OCA\Music\Utility\FilesUtil;
36
use OCA\Music\Utility\StringUtil;
37
use OCA\Music\Utility\Util;
38
39
use Symfony\Component\Console\Output\OutputInterface;
40
41
class Scanner extends PublicEmitter {
42
	private Extractor $extractor;
43
	private ArtistBusinessLayer $artistBusinessLayer;
44
	private AlbumBusinessLayer $albumBusinessLayer;
45
	private TrackBusinessLayer $trackBusinessLayer;
46
	private PlaylistBusinessLayer $playlistBusinessLayer;
47
	private GenreBusinessLayer $genreBusinessLayer;
48
	private Cache $cache;
49
	private CoverService $coverService;
50
	private Logger $logger;
51
	private Maintenance $maintenance;
52
	private LibrarySettings $librarySettings;
53
	private IRootFolder $rootFolder;
54
	private IConfig $config;
55
	private IFactory $l10nFactory;
56
57
	public function __construct(
58
			ExtractorGetID3 $extractor,
59
			ArtistBusinessLayer $artistBusinessLayer,
60
			AlbumBusinessLayer $albumBusinessLayer,
61
			TrackBusinessLayer $trackBusinessLayer,
62
			PlaylistBusinessLayer $playlistBusinessLayer,
63
			GenreBusinessLayer $genreBusinessLayer,
64
			Cache $cache,
65
			CoverService $coverService,
66
			Logger $logger,
67
			Maintenance $maintenance,
68
			LibrarySettings $librarySettings,
69
			IRootFolder $rootFolder,
70
			IConfig $config,
71
			IFactory $l10nFactory
72
	) {
73
		$this->extractor = $extractor;
74
		$this->artistBusinessLayer = $artistBusinessLayer;
75
		$this->albumBusinessLayer = $albumBusinessLayer;
76
		$this->trackBusinessLayer = $trackBusinessLayer;
77
		$this->playlistBusinessLayer = $playlistBusinessLayer;
78
		$this->genreBusinessLayer = $genreBusinessLayer;
79
		$this->cache = $cache;
80
		$this->coverService = $coverService;
81
		$this->logger = $logger;
82
		$this->maintenance = $maintenance;
83
		$this->librarySettings = $librarySettings;
84
		$this->rootFolder = $rootFolder;
85
		$this->config = $config;
86
		$this->l10nFactory = $l10nFactory;
87
	}
88
89
	/**
90
	 * Gets called by 'post_write' (file creation, file update) and 'post_share' hooks
91
	 */
92
	public function update(File $file, string $userId, string $filePath) : void {
93
		$mimetype = $file->getMimeType();
94
		$isImage = StringUtil::startsWith($mimetype, 'image');
95
		$isAudio = (StringUtil::startsWith($mimetype, 'audio') && !self::isPlaylistMime($mimetype));
96
97
		if (($isImage || $isAudio) && $this->librarySettings->pathBelongsToMusicLibrary($filePath, $userId)) {
98
			$this->logger->debug("audio or image file within lib path updated: $filePath");
99
100
			if ($isImage) {
101
				$this->updateImage($file, $userId);
102
			} elseif ($isAudio) {
103
				$libraryRoot = $this->librarySettings->getFolder($userId);
104
				$this->updateAudio($file, $userId, $libraryRoot, $filePath, $mimetype, /*partOfScan=*/false);
105
			}
106
		}
107
	}
108
109
	public function fileMoved(File $file, string $userId) : void {
110
		$mimetype = $file->getMimeType();
111
112
		if (StringUtil::startsWith($mimetype, 'image')) {
113
			$this->logger->debug('image file moved: '. $file->getPath());
114
			// we don't need to track the identity of images and moving a file can be handled as it was
115
			// a file deletion followed by a file addition
116
			$this->deleteImage([$file->getId()], [$userId]);
117
			$this->updateImage($file, $userId);
118
		} elseif (StringUtil::startsWith($mimetype, 'audio') && !self::isPlaylistMime($mimetype)) {
119
			$this->logger->debug('audio file moved: '. $file->getPath());
120
			if ($this->librarySettings->pathBelongsToMusicLibrary($file->getPath(), $userId)) {
121
				// In the new path, the file (now or still) belongs to the library. Even if it was already in the lib,
122
				// the new path may have an influence on the album or artist name (in case of incomplete metadata).
123
				$libraryRoot = $this->librarySettings->getFolder($userId);
124
				$this->updateAudio($file, $userId, $libraryRoot, $file->getPath(), $mimetype, /*partOfScan=*/false);
125
			} else {
126
				// In the new path, the file doesn't (still or any longer) belong to the library. Remove it if
127
				// it happened to be in the library.
128
				$this->deleteAudio([$file->getId()], [$userId]);
129
			}
130
		}
131
	}
132
133
	public function folderMoved(Folder $folder, string $userId) : void {
134
		$audioFiles = $folder->searchByMime('audio');
135
		$audioCount = \count($audioFiles);
136
137
		if ($audioCount > 0) {
138
			$this->logger->debug("folder with $audioCount audio files moved: ". $folder->getPath());
139
140
			if ($this->librarySettings->pathBelongsToMusicLibrary($folder->getPath(), $userId)) {
141
				// The new path of the folder belongs to the library but this doesn't necessarily mean
142
				// that all the file paths below belong to the library, because of the path exclusions.
143
				// Each file needs to be checked and updated separately.
144
				if ($audioCount <= 15) {
145
					foreach ($audioFiles as $file) {
146
						\assert($file instanceof File); // a clue for PHPStan
147
						$this->fileMoved($file, $userId);
148
					}
149
				} else {
150
					// There are too many files to handle them now as we don't want to delay the move operation
151
					// too much. The user will be prompted to rescan the files upon opening the Music app.
152
					$this->trackBusinessLayer->markTracksDirty(ArrayUtil::extractIds($audioFiles), [$userId]);
153
				}
154
			} else {
155
				// The new path of the folder doesn't belong to the library so neither does any of the
156
				// contained files. Remove audio files from the lib if found.
157
				$this->deleteAudio(ArrayUtil::extractIds($audioFiles), [$userId]);
158
			}
159
		}
160
	}
161
162
	private static function isPlaylistMime(string $mime) : bool {
163
		return $mime == 'audio/mpegurl' || $mime == 'audio/x-scpls';
164
	}
165
166
	private function updateImage(File $file, string $userId) : void {
167
		$coverFileId = $file->getId();
168
		$parentFolderId = $file->getParent()->getId();
169
		if ($this->albumBusinessLayer->updateFolderCover($coverFileId, $parentFolderId)) {
170
			$this->logger->debug('updateImage - the image was set as cover for some album(s)');
171
			$this->cache->remove($userId, 'collection');
172
		}
173
174
		$artistIds = $this->artistBusinessLayer->updateCover($file, $userId, $this->userL10N($userId));
175
		foreach ($artistIds as $artistId) {
176
			$this->logger->debug("updateImage - the image was set as cover for the artist $artistId");
177
			$this->coverService->removeArtistCoverFromCache($artistId, $userId);
178
		}
179
	}
180
181
	/**
182
	 * @return array Information about consumed time: ['analyze' => int|float, 'db update' => int|float]
183
	 */
184
	private function updateAudio(File $file, string $userId, Folder $libraryRoot, string $filePath, string $mimetype, bool $partOfScan) : array {
185
		$this->emit(self::class, 'update', [$filePath]);
186
187
		$time1 = \hrtime(true);
188
		$analysisEnabled = $this->librarySettings->getScanMetadataEnabled($userId);
189
		$meta = $this->extractMetadata($file, $libraryRoot, $filePath, $analysisEnabled);
190
		$fileId = $file->getId();
191
		$time2 = \hrtime(true);
192
193
		// add/update artist and get artist entity
194
		$artist = $this->artistBusinessLayer->addOrUpdateArtist($meta['artist'], $userId);
195
		$artistId = $artist->getId();
196
197
		// add/update albumArtist and get artist entity
198
		$albumArtist = $this->artistBusinessLayer->addOrUpdateArtist($meta['albumArtist'], $userId);
199
		$albumArtistId = $albumArtist->getId();
200
201
		// add/update album and get album entity
202
		$album = $this->albumBusinessLayer->addOrUpdateAlbum($meta['album'], $albumArtistId, $userId);
203
		$albumId = $album->getId();
204
205
		// add/update genre and get genre entity
206
		$genre = $this->genreBusinessLayer->addOrUpdateGenre($meta['genre'], $userId);
207
208
		// add/update track and get track entity
209
		$track = $this->trackBusinessLayer->addOrUpdateTrack(
210
				$meta['title'], $meta['trackNumber'], $meta['discNumber'], $meta['year'], $genre->getId(),
211
				$artistId, $albumId, $fileId, $mimetype, $userId, $meta['length'], $meta['bitrate']);
212
213
		// if present, use the embedded album art as cover for the respective album
214
		if ($meta['picture'] != null) {
215
			// during scanning, don't repeatedly change the file providing the art for the album
216
			if ($album->getCoverFileId() === null || !$partOfScan) {
217
				$this->albumBusinessLayer->setCover($fileId, $albumId);
218
				$this->coverService->removeAlbumCoverFromCache($albumId, $userId);
219
			}
220
		}
221
		// if this file is an existing file which previously was used as cover for an album but now
222
		// the file no longer contains any embedded album art
223
		elseif ($album->getCoverFileId() === $fileId) {
224
			$this->albumBusinessLayer->removeCovers([$fileId]);
225
			$this->findEmbeddedCoverForAlbum($albumId, $userId, $libraryRoot);
226
			$this->coverService->removeAlbumCoverFromCache($albumId, $userId);
227
		}
228
		$time3 = \hrtime(true);
229
230
		if (!$partOfScan) {
231
			// invalidate the cache as the music collection was changed
232
			$this->cache->remove($userId, 'collection');
233
		}
234
235
		$this->logger->debug('imported entities - ' .
236
				"artist: $artistId, albumArtist: $albumArtistId, album: $albumId, track: {$track->getId()}");
237
238
		return [
239
			'analyze' => $time2 - $time1,
240
			'db update' => $time3 - $time2
241
		];
242
	}
243
244
	private function extractMetadata(File $file, Folder $libraryRoot, string $filePath, bool $analyzeFile) : array {
245
		$fieldsFromFileName = self::parseFileName($file->getName());
246
		$fileInfo = $analyzeFile ? $this->extractor->extract($file) : [];
247
		$meta = [];
248
249
		// Track artist and album artist
250
		$meta['artist'] = ExtractorGetID3::getTag($fileInfo, 'artist');
251
		$meta['albumArtist'] = ExtractorGetID3::getFirstOfTags($fileInfo, ['band', 'albumartist', 'album artist', 'album_artist']);
252
253
		// use artist and albumArtist as fallbacks for each other
254
		if (!StringUtil::isNonEmptyString($meta['albumArtist'])) {
255
			$meta['albumArtist'] = $meta['artist'];
256
		}
257
258
		if (!StringUtil::isNonEmptyString($meta['artist'])) {
259
			$meta['artist'] = $meta['albumArtist'];
260
		}
261
262
		if (!StringUtil::isNonEmptyString($meta['artist'])) {
263
			// neither artist nor albumArtist set in fileinfo, use the second level parent folder name
264
			// unless it is the user's library root folder
265
			$dirPath = \dirname(\dirname($filePath));
266
			if (StringUtil::startsWith($libraryRoot->getPath(), $dirPath)) {
267
				$artistName = null;
268
			} else {
269
				$artistName = \basename($dirPath);
270
			}
271
272
			$meta['artist'] = $artistName;
273
			$meta['albumArtist'] = $artistName;
274
		}
275
276
		// title
277
		$meta['title'] = ExtractorGetID3::getTag($fileInfo, 'title');
278
		if (!StringUtil::isNonEmptyString($meta['title'])) {
279
			$meta['title'] = $fieldsFromFileName['title'];
280
		}
281
282
		// album
283
		$meta['album'] = ExtractorGetID3::getTag($fileInfo, 'album');
284
		if (!StringUtil::isNonEmptyString($meta['album'])) {
285
			// album name not set in fileinfo, use parent folder name as album name unless it is the user's library root folder
286
			$dirPath = \dirname($filePath);
287
			if ($libraryRoot->getPath() === $dirPath) {
288
				$meta['album'] = null;
289
			} else {
290
				$meta['album'] = \basename($dirPath);
291
			}
292
		}
293
294
		// track number
295
		$meta['trackNumber'] = ExtractorGetID3::getFirstOfTags(
296
			$fileInfo,
297
			['track_number', 'tracknumber', 'track'],
298
			$fieldsFromFileName['track_number']
299
		);
300
		$meta['trackNumber'] = self::normalizeOrdinal($meta['trackNumber']);
301
302
		// disc number
303
		$meta['discNumber'] = ExtractorGetID3::getFirstOfTags($fileInfo, ['disc_number', 'discnumber', 'part_of_a_set'], '1');
304
		$meta['discNumber'] = self::normalizeOrdinal($meta['discNumber']);
305
306
		// year
307
		$meta['year'] = ExtractorGetID3::getFirstOfTags($fileInfo, ['year', 'date', 'creation_date']);
308
		$meta['year'] = self::normalizeYear($meta['year']);
309
310
		$meta['genre'] = ExtractorGetID3::getTag($fileInfo, 'genre') ?: ''; // empty string used for "scanned but unknown"
311
312
		$meta['picture'] = ExtractorGetID3::getTag($fileInfo, 'picture', true);
313
314
		$meta['length'] = self::normalizeUnsigned($fileInfo['playtime_seconds'] ?? null);
315
316
		$meta['bitrate'] = self::normalizeUnsigned($fileInfo['audio']['bitrate'] ?? null);
317
318
		return $meta;
319
	}
320
321
	/**
322
	 * @param string[] $affectedUsers
323
	 * @param int[] $affectedAlbums
324
	 * @param int[] $affectedArtists
325
	 */
326
	private function invalidateCacheOnDelete(array $affectedUsers, array $affectedAlbums, array $affectedArtists) : void {
327
		// Delete may be for one file or for a folder containing thousands of albums.
328
		// If loads of albums got affected, then ditch the whole cache of the affected
329
		// users because removing the cached covers one-by-one could delay the delete
330
		// operation significantly.
331
		$albumCount = \count($affectedAlbums);
332
		$artistCount = \count($affectedArtists);
333
		$userCount = \count($affectedUsers);
334
335
		if ($albumCount + $artistCount > 100) {
336
			$this->logger->debug("Delete operation affected $albumCount albums and $artistCount artists. " .
337
								"Invalidate the whole cache of all affected users ($userCount).");
338
			foreach ($affectedUsers as $user) {
339
				$this->cache->remove($user);
340
			}
341
		} else {
342
			// remove the cached covers
343
			if ($artistCount > 0) {
344
				$this->logger->debug("Remove covers of $artistCount artist(s) from the cache (if present)");
345
				foreach ($affectedArtists as $artistId) {
346
					$this->coverService->removeArtistCoverFromCache($artistId);
347
				}
348
			}
349
350
			if ($albumCount > 0) {
351
				$this->logger->debug("Remove covers of $albumCount album(s) from the cache (if present)");
352
				foreach ($affectedAlbums as $albumId) {
353
					$this->coverService->removeAlbumCoverFromCache($albumId);
354
				}
355
			}
356
357
			// remove the cached collection regardless of if covers were affected; it may be that this
358
			// function got called after a track was deleted and there are no album/artist changes
359
			foreach ($affectedUsers as $user) {
360
				$this->cache->remove($user, 'collection');
361
			}
362
		}
363
	}
364
365
	/**
366
	 * @param int[] $fileIds
367
	 * @param string[]|null $userIds
368
	 * @return boolean true if anything was removed
369
	 */
370
	public function deleteAudio(array $fileIds, ?array $userIds=null) : bool {
371
		$result = $this->trackBusinessLayer->deleteTracks($fileIds, $userIds);
372
373
		if ($result) { // one or more tracks were removed
374
			$this->logger->debug('library updated when audio file(s) removed: '. \implode(', ', $fileIds));
375
376
			// remove obsolete artists and albums, and track references in playlists
377
			$this->albumBusinessLayer->deleteById($result['obsoleteAlbums']);
378
			$this->artistBusinessLayer->deleteById($result['obsoleteArtists']);
379
			$this->playlistBusinessLayer->removeTracksFromAllLists($result['deletedTracks']);
380
381
			// check if a removed track was used as embedded cover art file for a remaining album
382
			foreach ($result['remainingAlbums'] as $albumId) {
383
				if ($this->albumBusinessLayer->albumCoverIsOneOfFiles($albumId, $fileIds)) {
384
					$this->albumBusinessLayer->setCover(null, $albumId);
385
					$this->findEmbeddedCoverForAlbum($albumId);
386
					$this->coverService->removeAlbumCoverFromCache($albumId);
387
				}
388
			}
389
390
			$this->invalidateCacheOnDelete(
391
				$result['affectedUsers'],
392
				$result['obsoleteAlbums'],
393
				$result['obsoleteArtists']
394
			);
395
396
			$this->logger->debug('removed entities: ' . \json_encode($result));
397
			$this->emit(self::class, 'delete', [$result['deletedTracks'], $result['affectedUsers']]);
398
		}
399
400
		return $result !== false;
401
	}
402
403
	/**
404
	 * @param int[] $fileIds
405
	 * @param string[]|null $userIds
406
	 * @return boolean true if anything was removed
407
	 */
408
	private function deleteImage(array $fileIds, ?array $userIds=null) : bool {
409
		$affectedAlbums = $this->albumBusinessLayer->removeCovers($fileIds, $userIds);
410
		$affectedArtists = $this->artistBusinessLayer->removeCovers($fileIds, $userIds);
411
412
		$anythingAffected = (\count($affectedAlbums) + \count($affectedArtists) > 0);
413
414
		if ($anythingAffected) {
415
			$this->logger->debug('library covers updated when image file(s) removed: '. \implode(', ', $fileIds));
416
417
			$affectedUsers = \array_merge(
418
				ArrayUtil::extractUserIds($affectedAlbums),
419
				ArrayUtil::extractUserIds($affectedArtists)
420
			);
421
			$affectedUsers = \array_unique($affectedUsers);
422
423
			$this->invalidateCacheOnDelete(
424
				$affectedUsers,
425
				ArrayUtil::extractIds($affectedAlbums),
426
				ArrayUtil::extractIds($affectedArtists)
427
			);
428
		}
429
430
		return $anythingAffected;
431
	}
432
433
	/**
434
	 * Gets called by 'unshare' hook and 'delete' hook
435
	 *
436
	 * @param int $fileId ID of the deleted files
437
	 * @param string[]|null $userIds the IDs of the users to remove the file from; if omitted,
438
	 *                               the file is removed from all users (ie. owner and sharees)
439
	 */
440
	public function delete(int $fileId, ?array $userIds=null) : void {
441
		// The removed file may or may not be of interesting type and belong to the library. It's
442
		// most efficient just to try to remove it as audio or image. It will take just a few simple
443
		// DB queries to notice if the file had nothing to do with our library.
444
		if (!$this->deleteAudio([$fileId], $userIds)) {
445
			$this->deleteImage([$fileId], $userIds);
446
		}
447
	}
448
449
	/**
450
	 * Remove all audio files and cover images in the given folder from the database.
451
	 * This gets called when a folder is deleted or unshared from the user.
452
	 *
453
	 * @param Folder $folder
454
	 * @param string[]|null $userIds the IDs of the users to remove the folder from; if omitted,
455
	 *                               the folder is removed from all users (ie. owner and sharees)
456
	 */
457
	public function deleteFolder(Folder $folder, ?array $userIds=null) : void {
458
		$audioFiles = $folder->searchByMime('audio');
459
		if (\count($audioFiles) > 0) {
460
			$this->deleteAudio(ArrayUtil::extractIds($audioFiles), $userIds);
461
		}
462
463
		// NOTE: When a folder is removed, we don't need to check for any image
464
		// files in the folder. This is because those images could be potentially
465
		// used as covers only on the audio files of the same folder and those
466
		// were already removed above.
467
	}
468
469
	/**
470
	 * search for image files by mimetype inside user specified library path
471
	 * (which defaults to user home dir)
472
	 *
473
	 * @return File[]
474
	 */
475
	private function getImageFiles(string $userId) : array {
476
		try {
477
			$folder = $this->librarySettings->getFolder($userId);
478
		} catch (\OCP\Files\NotFoundException $e) {
479
			return [];
480
		}
481
482
		$images = $folder->searchByMime('image');
483
484
		// filter out any images in the excluded folders
485
		return \array_filter($images, function ($image) use ($userId) {
486
			return ($image instanceof File) // assure PHPStan that Node indeed is File
487
				&& $this->librarySettings->pathBelongsToMusicLibrary($image->getPath(), $userId);
488
		});
489
	}
490
491
	private function getScannedFileIds(string $userId, ?string $path = null) : array {
492
		try {
493
			$folderId = $this->pathInLibToFolderId($userId, $path);
494
		} catch (\OCP\Files\NotFoundException $e) {
495
			return [];
496
		}
497
		return $this->trackBusinessLayer->findAllFileIds($userId, $folderId);
498
	}
499
500
	/**
501
	 * Find already scanned music files which have been modified since the time they were scanned
502
	 *
503
	 * @return int[]
504
	 */
505
	public function getDirtyMusicFileIds(string $userId, ?string $path = null) : array {
506
		try {
507
			$folderId = $this->pathInLibToFolderId($userId, $path);
508
		} catch (\OCP\Files\NotFoundException $e) {
509
			return [];
510
		}
511
		return $this->trackBusinessLayer->findDirtyFileIds($userId, $folderId);
512
	}
513
514
	/**
515
	 * Convert given path to a folder ID, provided that the path is within the music library.
516
	 * The result is null if the $path points to th root of the music library. The $path null
517
	 * is considered to point to the root of the lib (like in getMusicFolder).
518
	 */
519
	private function pathInLibToFolderId(string $userId, ?string $path = null) : ?int {
520
		$folderId = null;
521
		if (!empty($path)) {
522
			$folderId = $this->getMusicFolder($userId, $path)->getId();
523
			if ($folderId == $this->getMusicFolder($userId, null)->getId()) {
524
				// the path just pointed to the root of the library so it doesn't actually limit anything
525
				$folderId = null;
526
			}
527
		}
528
		return $folderId;
529
	}
530
531
	private function getMusicFolder(string $userId, ?string $path) : Folder {
532
		$folder = $this->librarySettings->getFolder($userId);
533
534
		if (!empty($path)) {
535
			$userFolder = $this->resolveUserFolder($userId);
536
			$requestedFolder = FilesUtil::getFolderFromRelativePath($userFolder, $path);
537
			if ($folder->isSubNode($requestedFolder) || $folder->getPath() == $requestedFolder->getPath()) {
538
				$folder = $requestedFolder;
539
			} else {
540
				throw new \OCP\Files\NotFoundException();
541
			}
542
		}
543
544
		return $folder;
545
	}
546
547
	/**
548
	 * Search for music files by mimetype inside user specified library path
549
	 * (which defaults to user home dir).
550
	 * Optionally, limit the search to only the specified path. If this path doesn't
551
	 * point within the library path, then nothing will be found.
552
	 *
553
	 * @return int[]
554
	 */
555
	public function getAllMusicFileIds(string $userId, ?string $path = null) : array {
556
		try {
557
			$folder = $this->getMusicFolder($userId, $path);
558
		} catch (\OCP\Files\NotFoundException $e) {
559
			return [];
560
		}
561
562
		// Search files with mime 'audio/*' but filter out the playlist files and files under excluded folders
563
		$files = $folder->searchByMime('audio');
564
565
		$files = \array_filter($files, fn($f) =>
566
					!self::isPlaylistMime($f->getMimeType())
567
					&& $this->librarySettings->pathBelongsToMusicLibrary($f->getPath(), $userId)
568
		);
569
570
		return \array_values(ArrayUtil::extractIds($files)); // the array may be sparse before array_values
571
	}
572
573
	/**
574
	 * @return array{unscannedFiles: int[], obsoleteFiles: int[], dirtyFiles: int[], scannedCount: int}
575
	 */
576
	public function getStatusOfLibraryFiles(string $userId, ?string $path = null) : array {
577
		$scannedIds = $this->getScannedFileIds($userId, $path);
578
		$availableIds = $this->getAllMusicFileIds($userId, $path);
579
580
		return [
581
			'unscannedFiles' => ArrayUtil::diff($availableIds, $scannedIds),
582
			'obsoleteFiles' => ArrayUtil::diff($scannedIds, $availableIds),
583
			'dirtyFiles' => $this->getDirtyMusicFileIds($userId, $path),
584
			'scannedCount' => \count($scannedIds)
585
		];
586
	}
587
588
	/**
589
	 * @return array ['count' => int, 'anlz_time' => int, 'db_time' => int], times in milliseconds
590
	 */
591
	public function scanFiles(string $userId, array $fileIds, ?OutputInterface $debugOutput = null) : array {
592
		$count = \count($fileIds);
593
		$this->logger->debug("Scanning $count files of user $userId");
594
595
		// back up the execution time limit
596
		$executionTime = \intval(\ini_get('max_execution_time'));
597
		// set execution time limit to unlimited
598
		\set_time_limit(0);
599
600
		$libraryRoot = $this->librarySettings->getFolder($userId);
601
602
		$count = 0;
603
		$totalAnalyzeTime = 0;
604
		$totalDbTime = 0;
605
		foreach ($fileIds as $fileId) {
606
			$this->cache->set($userId, 'scanning', (string)\time()); // update scanning status to prevent simultaneous background cleanup execution
607
608
			$file = $libraryRoot->getById($fileId)[0] ?? null;
609
			if ($file != null && !$this->librarySettings->pathBelongsToMusicLibrary($file->getPath(), $userId)) {
610
				$this->emit(self::class, 'exclude', [$file->getPath()]);
611
				$file = null;
612
			}
613
			if ($file instanceof File) {
614
				$memBefore = $debugOutput ? \memory_get_usage(true) : 0;
615
				list('analyze' => $analyzeTime, 'db update' => $dbTime)
616
					= $this->updateAudio($file, $userId, $libraryRoot, $file->getPath(), $file->getMimetype(), /*partOfScan=*/true);
617
				if ($debugOutput) {
618
					$memAfter = \memory_get_usage(true);
619
					$memDelta = $memAfter - $memBefore;
620
					$fmtMemAfter = Util::formatFileSize($memAfter);
621
					$fmtMemDelta = \mb_chr(0x0394) . Util::formatFileSize($memDelta);
622
					$path = $file->getPath();
623
					$fmtAnalyzeTime = 'anlz:' . (int)($analyzeTime / 1000000) . 'ms';
624
					$fmtDbTime = 'db:' . (int)($dbTime / 1000000) . 'ms';
625
					$debugOutput->writeln("\e[1m $count \e[0m $fmtMemAfter \e[1m ($fmtMemDelta) \e[0m $fmtAnalyzeTime \e[1m $fmtDbTime \e[0m $path");
626
				}
627
				$count++;
628
				$totalAnalyzeTime += $analyzeTime;
629
				$totalDbTime += $dbTime;
630
			} else {
631
				$this->logger->info("File with id $fileId not found for user $userId, removing it from the library if present");
632
				$this->deleteAudio([$fileId], [$userId]);
633
			}
634
		}
635
636
		// reset execution time limit
637
		\set_time_limit($executionTime);
638
639
		// invalidate the cache as the collection has changed and clear the 'scanning' status
640
		$this->cache->remove($userId, 'collection');
641
		$this->cache->remove($userId, 'scanning'); // this isn't completely thread-safe, in case there would be multiple simultaneous scan jobs for the same user for some bizarre reason
642
643
		return [
644
			'count' => $count,
645
			'anlz_time' => (int)($totalAnalyzeTime / 1000000),
646
			'db_time' => (int)($totalDbTime / 1000000)
647
		];
648
	}
649
650
	/**
651
	 * Check the availability of all the indexed audio files of the user. Remove
652
	 * from the index any which are not available.
653
	 * @return int Number of removed files
654
	 */
655
	public function removeUnavailableFiles(string $userId) : int {
656
		$indexedFiles = $this->getScannedFileIds($userId);
657
		$availableFiles = $this->getAllMusicFileIds($userId);
658
		$unavailableFiles = ArrayUtil::diff($indexedFiles, $availableFiles);
659
660
		$count = \count($unavailableFiles);
661
		if ($count > 0) {
662
			$this->logger->info('The following files are no longer available within the library of the '.
663
				"user $userId, removing: " . (string)\json_encode($unavailableFiles));
664
			$this->deleteAudio($unavailableFiles, [$userId]);
665
		}
666
		return $count;
667
	}
668
669
	/**
670
	 * Parse and get basic info about a file. The file does not have to be indexed in the database.
671
	 */
672
	public function getFileInfo(int $fileId, string $userId, Folder $userFolder) : ?array {
673
		$info = $this->getIndexedFileInfo($fileId, $userId, $userFolder)
674
			?: $this->getUnindexedFileInfo($fileId, $userId, $userFolder);
675
676
		// base64-encode and wrap the cover image if available
677
		if ($info !== null && $info['cover'] !== null) {
678
			$mime = $info['cover']['mimetype'];
679
			$content = $info['cover']['content'];
680
			$info['cover'] = 'data:' . $mime. ';base64,' . \base64_encode($content);
681
		}
682
683
		return $info;
684
	}
685
686
	private function getIndexedFileInfo(int $fileId, string $userId, Folder $userFolder) : ?array {
687
		$track = $this->trackBusinessLayer->findByFileId($fileId, $userId);
688
		if ($track !== null) {
689
			$artist = $this->artistBusinessLayer->find($track->getArtistId(), $userId);
690
			$album = $this->albumBusinessLayer->find($track->getAlbumId(), $userId);
691
			return [
692
				'title'      => $track->getTitle(),
693
				'artist'     => $artist->getName(),
694
				'cover'      => $this->coverService->getCover($album, $userId, $userFolder),
695
				'in_library' => true
696
			];
697
		}
698
		return null;
699
	}
700
701
	private function getUnindexedFileInfo(int $fileId, string $userId, Folder $userFolder) : ?array {
702
		$file = $userFolder->getById($fileId)[0] ?? null;
703
		if ($file instanceof File) {
704
			$metadata = $this->extractMetadata($file, $userFolder, $file->getPath(), true);
705
			$cover = $metadata['picture'];
706
			if ($cover != null) {
707
				$cover = $this->coverService->scaleDownAndCrop([
708
					'mimetype' => $cover['image_mime'],
709
					'content' => $cover['data']
710
				], 200);
711
			}
712
			return [
713
				'title'      => $metadata['title'],
714
				'artist'     => $metadata['artist'],
715
				'cover'      => $cover,
716
				'in_library' => $this->librarySettings->pathBelongsToMusicLibrary($file->getPath(), $userId)
717
			];
718
		}
719
		return null;
720
	}
721
722
	/**
723
	 * Update music path
724
	 */
725
	public function updatePath(string $oldPath, string $newPath, string $userId) : void {
726
		$this->logger->info("Changing music collection path of user $userId from $oldPath to $newPath");
727
728
		$userHome = $this->resolveUserFolder($userId);
729
730
		try {
731
			$oldFolder = FilesUtil::getFolderFromRelativePath($userHome, $oldPath);
732
			$newFolder = FilesUtil::getFolderFromRelativePath($userHome, $newPath);
733
734
			if ($newFolder->getPath() === $oldFolder->getPath()) {
735
				$this->logger->debug('New collection path is the same as the old path, nothing to do');
736
			} elseif ($newFolder->isSubNode($oldFolder)) {
737
				$this->logger->debug('New collection path is (grand) parent of old path, previous content is still valid');
738
			} elseif ($oldFolder->isSubNode($newFolder)) {
739
				$this->logger->debug('Old collection path is (grand) parent of new path, checking the validity of previous content');
740
				$this->removeUnavailableFiles($userId);
741
			} else {
742
				$this->logger->debug('Old and new collection paths are unrelated, erasing the previous collection content');
743
				$this->maintenance->resetLibrary($userId);
744
			}
745
		} catch (\OCP\Files\NotFoundException $e) {
746
			$this->logger->warning('One of the paths was invalid, erasing the previous collection content');
747
			$this->maintenance->resetLibrary($userId);
748
		}
749
	}
750
751
	/**
752
	 * Find external cover images for albums which do not yet have one.
753
	 * Target either one user or all users.
754
	 * Optionally, limit the search to only the specified path. If this path doesn't point within the library path,
755
	 * then nothing will be found. Path is not supported when all users are targeted.
756
	 *
757
	 * @return bool true if any albums were updated; false otherwise
758
	 */
759
	public function findAlbumCovers(?string $userId = null, ?string $path = null) : bool {
760
		$folderId = null;
761
		if ($path !== null) {
762
			if ($userId === null) {
763
				throw new \InvalidArgumentException('Argument $path is not supported without argument $userId');
764
			}
765
			try {
766
				$folderId = $this->pathInLibToFolderId($userId, $path);
767
			} catch (\OCP\Files\NotFoundException $e) {
768
				return false;
769
			}
770
		}
771
772
		$affectedUsers = $this->albumBusinessLayer->findCovers($userId, $folderId);
773
		// scratch the cache for those users whose music collection was touched
774
		foreach ($affectedUsers as $user) {
775
			$this->cache->remove($user, 'collection');
776
			$this->logger->debug('album cover(s) were found for user '. $user);
777
		}
778
		return !empty($affectedUsers);
779
	}
780
781
	/**
782
	 * Find external cover images for artists which do not yet have one.
783
	 * @param string $userId
784
	 * @return bool true if any albums were updated; false otherwise
785
	 */
786
	public function findArtistCovers(string $userId) : bool {
787
		$allImages = $this->getImageFiles($userId);
788
		return $this->artistBusinessLayer->updateCovers($allImages, $userId, $this->userL10N($userId));
789
	}
790
791
	public function resolveUserFolder(string $userId) : Folder {
792
		return $this->rootFolder->getUserFolder($userId);
793
	}
794
795
	/**
796
	 * Get the selected localization of the user, even in case there is no logged in user in the context.
797
	 */
798
	private function userL10N(string $userId) : IL10N {
799
		$languageCode = $this->config->getUserValue($userId, 'core', 'lang');
800
		return $this->l10nFactory->get('music', $languageCode);
801
	}
802
803
	/**
804
	 * @param int|float|string|null $ordinal
805
	 */
806
	private static function normalizeOrdinal(/*mixed*/ $ordinal) : ?int {
807
		if (\is_string($ordinal)) {
808
			// convert format '1/10' to '1'
809
			$ordinal = \explode('/', $ordinal)[0];
810
		}
811
812
		// check for numeric values - cast them to int and verify it's a natural number above 0
813
		if (\is_numeric($ordinal) && ((int)$ordinal) > 0) {
814
			$ordinal = (int)Util::limit((int)$ordinal, 0, Util::SINT32_MAX); // can't use UINT32_MAX since PostgreSQL has no unsigned types
815
		} else {
816
			$ordinal = null;
817
		}
818
819
		return $ordinal;
820
	}
821
822
	private static function parseFileName(string $fileName) : array {
823
		$matches = null;
824
		// If the file name starts e.g like "12. something" or "12 - something", the
825
		// preceding number is extracted as track number. Everything after the optional
826
		// track number + delimiters part but before the file extension is extracted as title.
827
		// The file extension consists of a '.' followed by 1-4 "word characters".
828
		if (\preg_match('/^((\d+)\s*[.-]\s+)?(.+)\.(\w{1,4})$/', $fileName, $matches) === 1) {
829
			return ['track_number' => $matches[2], 'title' => $matches[3]];
830
		} else {
831
			return ['track_number' => null, 'title' => $fileName];
832
		}
833
	}
834
835
	/**
836
	 * @param int|float|string|null $date
837
	 */
838
	private static function normalizeYear(/*mixed*/ $date) : ?int {
839
		$year = null;
840
		$matches = null;
841
842
		if (\is_numeric($date)) {
843
			$year = (int)$date; // the date is a valid year as-is
844
		} elseif (\is_string($date) && \preg_match('/^(\d\d\d\d)-\d\d-\d\d.*/', $date, $matches) === 1) {
845
			$year = (int)$matches[1]; // year from ISO-formatted date yyyy-mm-dd
846
		} else {
847
			$year = null;
848
		}
849
850
		return ($year === null) ? null : (int)Util::limit($year, Util::SINT32_MIN, Util::SINT32_MAX);
851
	}
852
853
	/**
854
	 * @param int|float|string|null $value
855
	 */
856
	private static function normalizeUnsigned(/*mixed*/ $value) : ?int {
857
		if (\is_numeric($value)) {
858
			$value = (int)\round((float)$value);
859
			$value = (int)Util::limit($value, 0, Util::SINT32_MAX); // can't use UINT32_MAX since PostgreSQL has no unsigned types
860
		} else {
861
			$value = null;
862
		}
863
		return $value;
864
	}
865
866
	/**
867
	 * Loop through the tracks of an album and set the first track containing embedded cover art
868
	 * as cover file for the album
869
	 * @param int $albumId
870
	 * @param string|null $userId name of user, deducted from $albumId if omitted
871
	 * @param Folder|null $baseFolder base folder for the search, library root of $userId is used if omitted
872
	 */
873
	private function findEmbeddedCoverForAlbum(int $albumId, ?string $userId=null, ?Folder $baseFolder=null) : void {
874
		if ($userId === null) {
875
			$userId = $this->albumBusinessLayer->findAlbumOwner($albumId);
876
		}
877
		if ($baseFolder === null) {
878
			$baseFolder = $this->librarySettings->getFolder($userId);
879
		}
880
881
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $userId);
882
		foreach ($tracks as $track) {
883
			$file = $baseFolder->getById($track->getFileId())[0] ?? null;
884
			if ($file instanceof File) {
885
				$image = $this->extractor->parseEmbeddedCoverArt($file);
886
				if ($image != null) {
887
					$this->albumBusinessLayer->setCover($track->getFileId(), $albumId);
888
					break;
889
				}
890
			}
891
		}
892
	}
893
}
894