Completed
Push — scalability_improvements ( 06b4ba...2da7a3 )
by Pauli
12:13
created

Scanner   D

Complexity

Total Complexity 85

Size/Duplication

Total Lines 524
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 11

Importance

Changes 0
Metric Value
wmc 85
lcom 1
cbo 11
dl 0
loc 524
rs 4.5142
c 0
b 0
f 0

22 Methods

Rating   Name   Duplication   Size   Complexity  
B __construct() 0 31 2
A updateById() 0 17 4
F update() 0 158 26
A parseEmbeddedCoverArt() 0 4 1
B delete() 0 36 6
B getMusicFiles() 0 16 5
A getScannedFiles() 0 3 1
A rescan() 0 12 2
A batchRescan() 0 9 1
B doRescan() 0 53 7
A getScanState() 0 7 1
A updatePath() 0 20 3
A findCovers() 0 8 2
A startsWith() 0 3 1
A isNullOrEmpty() 0 3 2
A getId3Tag() 0 9 3
A getFirstOfId3Tags() 0 9 3
A normalizeOrdinal() 0 14 3
A parseFileName() 0 11 2
A normalizeYear() 0 9 3
A fileIsCoverForAlbum() 0 4 2
B findEmbeddedCoverForAlbum() 0 16 5

How to fix   Complexity   

Complex Class

Complex classes like Scanner often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Scanner, and based on these observations, apply Extract Interface, too.

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 rescan() {
338
		$this->logger->log('Rescan: process next 20 tracks of user ' . $this->userId, 'debug');
339
340
		$result = $this->doRescan($this->userId, $this->userFolder, 20);
341
342
		// Log each step on 'debug' level and the final step on 'info' level
343
		$logLevel = ($result['processed'] >= $result['total']) ? 'info' : 'debug';
344
345
		$this->logger->log(sprintf('Rescan for user %s finished (%d/%d)',
346
				$this->userId, $result['processed'], $result['total']), $logLevel);
347
		return $result;
348
	}
349
350
	public function batchRescan($userId, $userHome, OutputInterface $debugOutput = null) {
351
		$this->logger->log('Batch rescan started for user ' . $userId, 'info');
352
353
		$result = $this->doRescan($userId, $userHome, 1000000, $debugOutput);
354
355
		$this->logger->log(sprintf('Batch rescan for user %s finished (%d/%d), %d new tracks',
356
				$userId, $result['processed'], $result['total'], $result['scanned']), 'info');
357
		return $result;
358
	}
359
360
	/**
361
	 * Scan the filebase of the given user for unindexed music files and add those to the database.
362
	 */
363
	private function doRescan($userId, $userHome, $maxTracksToProcess, OutputInterface $debugOutput = null) {
364
		// back up the execution time limit
365
		$executionTime = intval(ini_get('max_execution_time'));
366
		// set execution time limit to unlimited
367
		set_time_limit(0);
368
369
		$fileIds = $this->getScannedFiles($userId);
370
		$music = $this->getMusicFiles($userId, $userHome);
371
372
		$count = 0;
373
		foreach ($music as $file) {
374
			if($count >= $maxTracksToProcess) {
375
				// break scan - maximum number of files are already scanned
376
				break;
377
			}
378
			try {
379
				if(in_array($file->getId(), $fileIds)) {
380
					// skip this file as it's already scanned
381
					continue;
382
				}
383
			} catch (\OCP\Files\NotFoundException $e) {
384
				// just ignore the error
385
				$this->logger->log('updateById - file not found - '. $file , 'debug');
386
				continue;
387
			}
388
			if($debugOutput) {
389
				$before = memory_get_usage(true);
390
			}
391
			$this->update($file, $userId, $userHome);
392
			if($debugOutput) {
393
				$after = memory_get_usage(true);
394
				$diff = $after - $before;
395
				$afterFileSize = new FileSize($after);
396
				$diffFileSize = new FileSize($diff);
397
				$humanFilesizeAfter = $afterFileSize->getHumanReadable();
398
				$humanFilesizeDiff = $diffFileSize->getHumanReadable();
399
				$path = $file->getPath();
400
				$debugOutput->writeln("\e[1m $count \e[0m $humanFilesizeAfter \e[1m $diff \e[0m ($humanFilesizeDiff) $path");
401
			}
402
			$count++;
403
		}
404
		// find album covers
405
		$this->albumBusinessLayer->findCovers();
406
407
		// reset execution time limit
408
		set_time_limit($executionTime);
409
410
		return [
411
			'processed' => count($fileIds) + $count,
412
			'scanned' => $count,
413
			'total' => count($music)
414
		];
415
	}
416
417
	/**
418
	 * Return the state of the scanning for the current user 
419
	 * in the same format as the rescan functions
420
	 */
421
	public function getScanState() {
422
		return [
423
			'processed' => $this->trackBusinessLayer->count($this->userId),
424
			'scanned' => 0,
425
			'total' => count($this->getMusicFiles($this->userId, $this->userFolder))
426
		];
427
	}
428
429
	/**
430
	 * Update music path
431
	 */
432
	public function updatePath($path, $userId = null) {
433
		// TODO currently this function is quite dumb
434
		// it just drops all entries of an user from the tables
435
		if ($userId === null) {
436
			$userId = $this->userId;
437
		}
438
439
		$sqls = array(
440
			'DELETE FROM `*PREFIX*music_tracks` WHERE `user_id` = ?;',
441
			'DELETE FROM `*PREFIX*music_albums` WHERE `user_id` = ?;',
442
			'DELETE FROM `*PREFIX*music_artists` WHERE `user_id` = ?;',
443
			'UPDATE *PREFIX*music_playlists SET track_ids=NULL WHERE `user_id` = ?;'
444
		);
445
446
		foreach ($sqls as $sql) {
447
			$this->db->executeUpdate($sql, array($userId));
448
		}
449
450
		$this->cache->remove($userId);
451
	}
452
453
	public function findCovers() {
454
		$affectedUsers = $this->albumBusinessLayer->findCovers();
455
		// scratch the cache for those users whose music collection was touched
456
		foreach ($affectedUsers as $user) {
457
			$this->cache->remove($user);
458
			$this->logger->log('album cover(s) were found for user '. $user , 'debug');
459
		}
460
	}
461
462
	private static function startsWith($string, $potentialStart) {
463
		return substr($string, 0, strlen($potentialStart)) === $potentialStart;
464
	}
465
466
	private static function isNullOrEmpty($string) {
467
		return $string === null || $string === '';
468
	}
469
470
	private static function getId3Tag($fileInfo, $tag) {
471
		if(array_key_exists('comments', $fileInfo)) {
472
			$comments = $fileInfo['comments'];
473
			if(array_key_exists($tag, $comments)) {
474
				return $comments[$tag][0];
475
			}
476
		}
477
		return null;
478
	}
479
480
	private static function getFirstOfId3Tags($fileInfo, array $tags, $defaultValue = null) {
481
		foreach ($tags as $tag) {
482
			$value = self::getId3Tag($fileInfo, $tag);
483
			if (!self::isNullOrEmpty($value)) {
484
				return $value;
485
			}
486
		}
487
		return $defaultValue;
488
	}
489
490
	private static function normalizeOrdinal($ordinal) {
491
		// convert format '1/10' to '1'
492
		$tmp = explode('/', $ordinal);
493
		$ordinal = $tmp[0];
494
495
		// check for numeric values - cast them to int and verify it's a natural number above 0
496
		if(is_numeric($ordinal) && ((int)$ordinal) > 0) {
497
			$ordinal = (int)$ordinal;
498
		} else {
499
			$ordinal = null;
500
		}
501
502
		return $ordinal;
503
	}
504
505
	private static function parseFileName($fileName) {
506
		// If the file name starts e.g like "12 something" or "12. something" or "12 - something",
507
		// the preceeding number is extracted as track number. Everything after the optional track
508
		// number + delimiters part but before the file extension is extracted as title.
509
		// The file extension consists of a '.' followed by 1-4 "word characters".
510
		if(preg_match('/^((\d+)\s*[\s.-]\s*)?(.+)\.(\w{1,4})$/', $fileName, $matches) === 1) {
511
			return ['track_number' => $matches[2], 'title' => $matches[3]];
512
		} else {
513
			return ['track_number' => null, 'title' => $fileName];
514
		}
515
	}
516
517
	private static function normalizeYear($date) {
518
		if(ctype_digit($date)) {
519
			return $date; // the date is a valid year as-is
520
		} else if(preg_match('/^(\d\d\d\d)-\d\d-\d\d.*/', $date, $matches) === 1) {
521
			return $matches[1]; // year from ISO-formatted date yyyy-mm-dd
522
		} else {
523
			return null;
524
		}
525
	}
526
527
	private function fileIsCoverForAlbum($fileId, $albumId, $userId) {
528
		$album = $this->albumBusinessLayer->find($albumId, $userId);
529
		return ($album != null && $album->getCoverFileId() == $fileId);
530
	}
531
532
	/**
533
	 * Loop through the tracks of an album and set the first track containing embedded cover art
534
	 * as cover file for the album
535
	 * @param int $albumId
536
	 * @param int $userId
537
	 */
538
	private function findEmbeddedCoverForAlbum($albumId, $userId) {
539
		if ($this->userFolder != null) {
540
			$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $userId);
541
			foreach ($tracks as $track) {
542
				$nodes = $this->userFolder->getById($track->getFileId());
543
				if(count($nodes) > 0) {
544
					// parse the first valid node and check if it contains embedded cover art
545
					$image = $this->parseEmbeddedCoverArt($nodes[0]);
546
					if ($image != null) {
547
						$this->albumBusinessLayer->setCover($track->getFileId(), $albumId);
548
						break;
549
					}
550
				}
551
			}
552
		}
553
	}
554
}
555