Completed
Push — php-7.1 ( 77242e...1bf6d8 )
by Pauli
14:40
created

Scanner::doRescan()   B

Complexity

Conditions 7
Paths 2

Size

Total Lines 53
Code Lines 34

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 53
rs 7.5251
c 0
b 0
f 0
cc 7
eloc 34
nc 2
nop 4

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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