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

Scanner::__construct()   B

Complexity

Conditions 2
Paths 2

Size

Total Lines 29
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 29
rs 8.8571
c 0
b 0
f 0
cc 2
eloc 25
nc 2
nop 11

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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