Completed
Push — scalability_improvements ( 1f54dd...dcfdac )
by Pauli
19:38 queued 05:25
created

Scanner::getUnscannedMusicFileIds()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 15
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 15
rs 9.4285
c 0
b 0
f 0
cc 2
eloc 11
nc 2
nop 2
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
 * @copyright Morris Jobke 2013, 2014
11
 */
12
13
namespace OCA\Music\Utility;
14
15
use OC\Hooks\PublicEmitter;
16
17
use \OCP\Files\Folder;
18
use \OCP\IConfig;
19
20
use \OCA\Music\AppFramework\Core\Logger;
21
22
use \OCA\Music\BusinessLayer\ArtistBusinessLayer;
23
use \OCA\Music\BusinessLayer\AlbumBusinessLayer;
24
use \OCA\Music\BusinessLayer\TrackBusinessLayer;
25
use \OCA\Music\BusinessLayer\PlaylistBusinessLayer;
26
use \OCA\Music\Db\Cache;
27
use OCP\IDBConnection;
28
use Symfony\Component\Console\Output\OutputInterface;
29
30
31
class Scanner extends PublicEmitter {
32
33
	private $extractor;
34
	private $artistBusinessLayer;
35
	private $albumBusinessLayer;
36
	private $trackBusinessLayer;
37
	private $playlistBusinessLayer;
38
	private $cache;
39
	private $logger;
40
	/** @var IDBConnection  */
41
	private $db;
42
	private $userId;
43
	private $configManager;
44
	private $appName;
45
	private $userFolder;
46
47
	public function __construct(Extractor $extractor,
48
								ArtistBusinessLayer $artistBusinessLayer,
49
								AlbumBusinessLayer $albumBusinessLayer,
50
								TrackBusinessLayer $trackBusinessLayer,
51
								PlaylistBusinessLayer $playlistBusinessLayer,
52
								Cache $cache,
53
								Logger $logger,
54
								IDBConnection $db,
55
								$userId,
56
								IConfig $configManager,
57
								$appName,
58
								$userFolder = null){
59
		$this->extractor = $extractor;
60
		$this->artistBusinessLayer = $artistBusinessLayer;
61
		$this->albumBusinessLayer = $albumBusinessLayer;
62
		$this->trackBusinessLayer = $trackBusinessLayer;
63
		$this->playlistBusinessLayer = $playlistBusinessLayer;
64
		$this->cache = $cache;
65
		$this->logger = $logger;
66
		$this->db = $db;
67
		$this->userId = $userId;
68
		$this->configManager = $configManager;
69
		$this->appName = $appName;
70
		$this->userFolder = $userFolder;
71
72
		// Trying to enable stream support
73
		if(ini_get('allow_url_fopen') !== '1') {
74
			$this->logger->log('allow_url_fopen is disabled. It is strongly advised to enable it in your php.ini', 'warn');
75
			@ini_set('allow_url_fopen', '1');
76
		}
77
	}
78
79
	public function updateById($fileId, $userId) {
80
		// TODO properly initialize the user folder for external events (upload to public share)
81
		if ($this->userFolder === null) {
82
			return;
83
		}
84
85
		try {
86
			$files = $this->userFolder->getById($fileId);
87
			if(count($files) > 0) {
88
				// use first result
89
				$this->update($files[0], $userId, $this->userFolder);
90
			}
91
		} catch (\OCP\Files\NotFoundException $e) {
92
			// just ignore the error
93
			$this->logger->log('updateById - file not found - '. $fileId , 'debug');
94
		}
95
	}
96
97
	/**
98
	 * Gets called by 'post_write' hook (file creation, file update)
99
	 * @param \OCP\Files\Node $file the file
100
	 */
101
	public function update($file, $userId, $userHome){
102
		// debug logging
103
		$this->logger->log('update - '. $file->getPath() , 'debug');
104
105
		if(!($file instanceof \OCP\Files\File)) {
106
			return;
107
		}
108
109
		// TODO find a way to get this for a sharee
110
		$isSharee = $userId && $this->userId !== $userId;
111
112
		if(!$userId) {
113
			$userId = $this->userId;
114
		}
115
116
		if(!$userHome) {
117
			$userHome = $this->userFolder;
118
		}
119
120
		$musicPath = $this->configManager->getUserValue($userId, $this->appName, 'path');
121
		if($musicPath !== null || $musicPath !== '/' || $musicPath !== '') {
122
			// TODO verify
123
			$musicPath = $userHome->get($musicPath)->getPath();
124
			// skip files that aren't inside the user specified path (and also for sharees - TODO remove this)
125
			if(!$isSharee && !self::startsWith($file->getPath(), $musicPath)) {
126
				$this->logger->log('skipped - outside of specified path' , 'debug');
127
				return;
128
			}
129
		}
130
131
		$mimetype = $file->getMimeType();
132
133
		// debug logging
134
		$this->logger->log('update - mimetype '. $mimetype , 'debug');
135
		$this->emit('\OCA\Music\Utility\Scanner', 'update', array($file->getPath()));
136
137
		if(self::startsWith($mimetype, 'image')) {
138
			$coverFileId = $file->getId();
139
			$parentFolderId = $file->getParent()->getId();
140
			if ($this->albumBusinessLayer->updateFolderCover($coverFileId, $parentFolderId)) {
141
				$this->logger->log('update - the image was set as cover for some album(s)', 'debug');
142
				$this->cache->remove($userId);
143
			}
144
			return;
145
		}
146
147
		if(!self::startsWith($mimetype, 'audio') && !self::startsWith($mimetype, 'application/ogg')) {
148
			return;
149
		}
150
151
		if(ini_get('allow_url_fopen')) {
152
153
			$fieldsFromFileName = self::parseFileName($file->getName());
154
			$fileInfo = $this->extractor->extract($file);
155
156
			// Track artist and album artist
157
			$artist = self::getId3Tag($fileInfo, 'artist');
158
			$albumArtist = self::getFirstOfId3Tags($fileInfo, ['band', 'albumartist', 'album artist', 'album_artist']);
159
160
			// use artist and albumArtist as fallbacks for each other
161
			if(self::isNullOrEmpty($albumArtist)){
162
				$albumArtist = $artist;
163
			}
164
165
			if(self::isNullOrEmpty($artist)){
166
				$artist = $albumArtist;
167
			}
168
169
			// set 'Unknown Artist' in case neither artist nor albumArtist was found
170
			if(self::isNullOrEmpty($artist)){
171
				$artist = null;
172
				$albumArtist = null;
173
			}
174
175
			// title
176
			$title = self::getId3Tag($fileInfo, 'title');
177
			if(self::isNullOrEmpty($title)){
178
				$title = $fieldsFromFileName['title'];
179
			}
180
181
			// album
182
			$album = self::getId3Tag($fileInfo, 'album');
183
			if(self::isNullOrEmpty($album)){
184
				// album name not set in fileinfo, use parent folder name as album name unless it is the root folder
185
				if ( $userHome->getId() === $file->getParent()->getId() ) {
186
					$album = null;
187
				} else {
188
					$album = $file->getParent()->getName();
189
				}
190
			}
191
192
			// track number
193
			$trackNumber = self::getFirstOfId3Tags($fileInfo, ['track_number', 'tracknumber', 'track'], 
194
					$fieldsFromFileName['track_number']);
195
			$trackNumber = self::normalizeOrdinal($trackNumber);
196
197
			// disc number
198
			$discNumber = self::getFirstOfId3Tags($fileInfo, ['discnumber', 'part_of_a_set'], '1');
199
			$discNumber = self::normalizeOrdinal($discNumber);
200
201
			// year
202
			$year = self::getFirstOfId3Tags($fileInfo, ['year', 'date']);
203
			$year = self::normalizeYear($year);
204
205
			$fileId = $file->getId();
206
207
			$length = null;
208
			if (array_key_exists('playtime_seconds', $fileInfo)) {
209
				$length = ceil($fileInfo['playtime_seconds']);
210
			}
211
212
			$bitrate = null;
213
			if (array_key_exists('audio', $fileInfo) && array_key_exists('bitrate', $fileInfo['audio'])) {
214
				$bitrate = $fileInfo['audio']['bitrate'];
215
			}
216
217
			// debug logging
218
			$this->logger->log('extracted metadata - ' .
219
				sprintf('artist: %s, albumArtist: %s, album: %s, title: %s, track#: %s, disc#: %s, year: %s, mimetype: %s, length: %s, bitrate: %s, fileId: %i, this->userId: %s, userId: %s',
220
					$artist, $albumArtist, $album, $title, $trackNumber, $discNumber, $year, $mimetype, $length, $bitrate, $fileId, $this->userId, $userId), 'debug');
221
222
			// add artist and get artist entity
223
			$artist = $this->artistBusinessLayer->addArtistIfNotExist($artist, $userId);
224
			$artistId = $artist->getId();
225
226
			// add albumArtist and get artist entity
227
			$albumArtist = $this->artistBusinessLayer->addArtistIfNotExist($albumArtist, $userId);
228
			$albumArtistId = $albumArtist->getId();
229
230
			// add album and get album entity
231
			$album = $this->albumBusinessLayer->addAlbumIfNotExist($album, $year, $discNumber, $albumArtistId, $userId);
232
			$albumId = $album->getId();
233
234
			// add track and get track entity; the track gets updated if it already exists
235
			$track = $this->trackBusinessLayer->addTrackIfNotExist($title, $trackNumber, $artistId,
236
				$albumId, $fileId, $mimetype, $userId, $length, $bitrate);
237
238
			// if present, use the embedded album art as cover for the respective album
239
			if(self::getId3Tag($fileInfo, 'picture') != null) {
240
				$this->albumBusinessLayer->setCover($fileId, $albumId);
241
			}
242
			// if this file is an existing file which previously was used as cover for an album but now
243
			// the file no longer contains any embedded album art
244
			else if($this->fileIsCoverForAlbum($fileId, $albumId, $userId)) {
245
				$this->albumBusinessLayer->removeCover($fileId);
246
				$this->findEmbeddedCoverForAlbum($albumId, $userId);
247
			}
248
249
			// invalidate the cache as the music collection was changed
250
			$this->cache->remove($userId);
251
252
			// debug logging
253
			$this->logger->log('imported entities - ' .
254
				sprintf('artist: %d, albumArtist: %d, album: %d, track: %d', $artistId, $albumArtistId, $albumId, $track->getId()),
255
				'debug');
256
		}
257
258
	}
259
260
	/**
261
	 * @param \OCP\Files\Node $musicFile
262
	 * @return Array with image MIME and content or null
263
	 */
264
	public function parseEmbeddedCoverArt($musicFile){
265
		$fileInfo = $this->extractor->extract($musicFile);
266
		return self::getId3Tag($fileInfo, 'picture');
267
	}
268
269
	/**
270
	 * Get called by 'unshare' hook and 'delete' hook
271
	 * @param int $fileId the id of the deleted file
272
	 * @param string $userId the user id of the user to delete the track from
273
	 */
274
	public function delete($fileId, $userId = null){
275
		// debug logging
276
		$this->logger->log('delete - '. $fileId , 'debug');
277
		$this->emit('\OCA\Music\Utility\Scanner', 'delete', array($fileId, $userId));
278
279
		if ($userId === null) {
280
			$userId = $this->userId;
281
		}
282
283
		$result = $this->trackBusinessLayer->deleteTrack($fileId, $userId);
284
285
		if ($result) { // this was a track file
286
			// remove obsolete artists and albums, and track references in playlists
287
			$this->albumBusinessLayer->deleteById($result['obsoleteAlbums']);
288
			$this->artistBusinessLayer->deleteById($result['obsoleteArtists']);
289
			$this->playlistBusinessLayer->removeTracksFromAllLists($result['deletedTracks']);
290
291
			// check if the removed track was used as embedded cover art file for a remaining album
292
			foreach ($result['remainingAlbums'] as $albumId) {
293
				if ($this->fileIsCoverForAlbum($fileId, $albumId, $userId)) {
294
					$this->albumBusinessLayer->removeCover($fileId);
295
					$this->findEmbeddedCoverForAlbum($albumId, $userId);
296
				}
297
			}
298
299
			// invalidate the cache as the music collection was changed
300
			$this->cache->remove($userId);
301
302
			// debug logging
303
			$this->logger->log('removed entities - ' . json_encode($result), 'debug');
304
		}
305
		// maybe this was an image file
306
		else if ($this->albumBusinessLayer->removeCover($fileId)) {
307
			$this->cache->remove($userId);
308
		}
309
	}
310
311
	/**
312
	 * search for files by mimetype inside an optional user specified path
313
	 *
314
	 * @return \OCP\Files\Node[]
315
	 */
316
	public function getMusicFiles($userId, $folder) {
317
		$musicPath = $this->configManager->getUserValue($userId, $this->appName, 'path');
318
319
		if($musicPath !== null && $musicPath !== '/' && $musicPath !== '') {
320
			try {
321
				$folder = $folder->get($musicPath);
322
			} catch (\OCP\Files\NotFoundException $e) {
323
				return array();
324
			}
325
		}
326
327
		$audio = $folder->searchByMime('audio');
328
		$ogg = $folder->searchByMime('application/ogg');
329
330
		return array_merge($audio, $ogg);
331
	}
332
333
	public function getScannedFiles($userId) {
334
		return $this->trackBusinessLayer->findAllFileIds($userId);
335
	}
336
337
	public function getUnscannedMusicFileIds($userId, $userHome) {
338
		$scannedIds = $this->getScannedFiles($userId);
339
		$musicFiles = $this->getMusicFiles($userId, $userHome);
340
		$allIds = array_map(function($f) { return $f->getId(); }, $musicFiles);
341
		$unscannedIds = array_values(array_diff($allIds, $scannedIds));
342
343
		$count = count($unscannedIds);
344
		if ($count) {
345
			$this->logger->log("Found $count unscanned music files for user $userId", 'info');
346
		} else {
347
			$this->logger->log("No unscanned music files for user $userId", 'debug');
348
		}
349
350
		return $unscannedIds;
351
	}
352
353
	public function scanFiles($userId, $userHome, $fileIds, OutputInterface $debugOutput = null) {
354
		$count = count($fileIds);
355
		$this->logger->log("Scanning $count files of user $userId", 'debug');
356
357
		// back up the execution time limit
358
		$executionTime = intval(ini_get('max_execution_time'));
359
		// set execution time limit to unlimited
360
		set_time_limit(0);
361
362
		$count = 0;
363
		foreach ($fileIds as $fileId) {
364
			$fileNodes = $userHome->getById($fileId);
365
			if (count($fileNodes) > 0) {
366
				$file = $fileNodes[0];
367
				if($debugOutput) {
368
					$before = memory_get_usage(true);
369
				}
370
				$this->update($file, $userId, $userHome);
371
				if($debugOutput) {
372
					$after = memory_get_usage(true);
373
					$diff = $after - $before;
374
					$afterFileSize = new FileSize($after);
375
					$diffFileSize = new FileSize($diff);
376
					$humanFilesizeAfter = $afterFileSize->getHumanReadable();
377
					$humanFilesizeDiff = $diffFileSize->getHumanReadable();
378
					$path = $file->getPath();
379
					$debugOutput->writeln("\e[1m $count \e[0m $humanFilesizeAfter \e[1m $diff \e[0m ($humanFilesizeDiff) $path");
380
				}
381
				$count++;
382
			}
383
			else {
384
				$this->logger->log("File with id $fileId not found for user $userId", 'warn');
385
			}
386
		}
387
388
		// reset execution time limit
389
		set_time_limit($executionTime);
390
391
		return $count;
392
	}
393
394
	/**
395
	 * Update music path
396
	 */
397
	public function updatePath($path, $userId = null) {
398
		// TODO currently this function is quite dumb
399
		// it just drops all entries of an user from the tables
400
		if ($userId === null) {
401
			$userId = $this->userId;
402
		}
403
404
		$sqls = array(
405
			'DELETE FROM `*PREFIX*music_tracks` WHERE `user_id` = ?;',
406
			'DELETE FROM `*PREFIX*music_albums` WHERE `user_id` = ?;',
407
			'DELETE FROM `*PREFIX*music_artists` WHERE `user_id` = ?;',
408
			'UPDATE *PREFIX*music_playlists SET track_ids=NULL WHERE `user_id` = ?;'
409
		);
410
411
		foreach ($sqls as $sql) {
412
			$this->db->executeUpdate($sql, array($userId));
413
		}
414
415
		$this->cache->remove($userId);
416
	}
417
418
	public function findCovers() {
419
		$affectedUsers = $this->albumBusinessLayer->findCovers();
420
		// scratch the cache for those users whose music collection was touched
421
		foreach ($affectedUsers as $user) {
422
			$this->cache->remove($user);
423
			$this->logger->log('album cover(s) were found for user '. $user , 'debug');
424
		}
425
		return !empty($affectedUsers);
426
	}
427
428
	private static function startsWith($string, $potentialStart) {
429
		return substr($string, 0, strlen($potentialStart)) === $potentialStart;
430
	}
431
432
	private static function isNullOrEmpty($string) {
433
		return $string === null || $string === '';
434
	}
435
436
	private static function getId3Tag($fileInfo, $tag) {
437
		if(array_key_exists('comments', $fileInfo)) {
438
			$comments = $fileInfo['comments'];
439
			if(array_key_exists($tag, $comments)) {
440
				return $comments[$tag][0];
441
			}
442
		}
443
		return null;
444
	}
445
446
	private static function getFirstOfId3Tags($fileInfo, array $tags, $defaultValue = null) {
447
		foreach ($tags as $tag) {
448
			$value = self::getId3Tag($fileInfo, $tag);
449
			if (!self::isNullOrEmpty($value)) {
450
				return $value;
451
			}
452
		}
453
		return $defaultValue;
454
	}
455
456
	private static function normalizeOrdinal($ordinal) {
457
		// convert format '1/10' to '1'
458
		$tmp = explode('/', $ordinal);
459
		$ordinal = $tmp[0];
460
461
		// check for numeric values - cast them to int and verify it's a natural number above 0
462
		if(is_numeric($ordinal) && ((int)$ordinal) > 0) {
463
			$ordinal = (int)$ordinal;
464
		} else {
465
			$ordinal = null;
466
		}
467
468
		return $ordinal;
469
	}
470
471
	private static function parseFileName($fileName) {
472
		// If the file name starts e.g like "12 something" or "12. something" or "12 - something",
473
		// the preceeding number is extracted as track number. Everything after the optional track
474
		// number + delimiters part but before the file extension is extracted as title.
475
		// The file extension consists of a '.' followed by 1-4 "word characters".
476
		if(preg_match('/^((\d+)\s*[\s.-]\s*)?(.+)\.(\w{1,4})$/', $fileName, $matches) === 1) {
477
			return ['track_number' => $matches[2], 'title' => $matches[3]];
478
		} else {
479
			return ['track_number' => null, 'title' => $fileName];
480
		}
481
	}
482
483
	private static function normalizeYear($date) {
484
		if(ctype_digit($date)) {
485
			return $date; // the date is a valid year as-is
486
		} else if(preg_match('/^(\d\d\d\d)-\d\d-\d\d.*/', $date, $matches) === 1) {
487
			return $matches[1]; // year from ISO-formatted date yyyy-mm-dd
488
		} else {
489
			return null;
490
		}
491
	}
492
493
	private function fileIsCoverForAlbum($fileId, $albumId, $userId) {
494
		$album = $this->albumBusinessLayer->find($albumId, $userId);
495
		return ($album != null && $album->getCoverFileId() == $fileId);
496
	}
497
498
	/**
499
	 * Loop through the tracks of an album and set the first track containing embedded cover art
500
	 * as cover file for the album
501
	 * @param int $albumId
502
	 * @param int $userId
503
	 */
504
	private function findEmbeddedCoverForAlbum($albumId, $userId) {
505
		if ($this->userFolder != null) {
506
			$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $userId);
507
			foreach ($tracks as $track) {
508
				$nodes = $this->userFolder->getById($track->getFileId());
509
				if(count($nodes) > 0) {
510
					// parse the first valid node and check if it contains embedded cover art
511
					$image = $this->parseEmbeddedCoverArt($nodes[0]);
512
					if ($image != null) {
513
						$this->albumBusinessLayer->setCover($track->getFileId(), $albumId);
514
						break;
515
					}
516
				}
517
			}
518
		}
519
	}
520
}
521