Passed
Push — feature/329_Subsonic_API ( d9d298...793ffa )
by Pauli
14:45
created

SubsonicController::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 27
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 11
nc 1
nop 12
dl 0
loc 27
rs 9.9
c 1
b 0
f 0

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 Pauli Järvinen <[email protected]>
10
 * @copyright Pauli Järvinen 2019
11
 */
12
13
namespace OCA\Music\Controller;
14
15
use \OCP\AppFramework\Controller;
0 ignored issues
show
Bug introduced by
The type OCP\AppFramework\Controller was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
16
use \OCP\AppFramework\Http\DataDisplayResponse;
0 ignored issues
show
Bug introduced by
The type OCP\AppFramework\Http\DataDisplayResponse was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
17
use \OCP\AppFramework\Http\JSONResponse;
0 ignored issues
show
Bug introduced by
The type OCP\AppFramework\Http\JSONResponse was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
18
use \OCP\IRequest;
0 ignored issues
show
Bug introduced by
The type OCP\IRequest was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
19
use \OCP\IURLGenerator;
0 ignored issues
show
Bug introduced by
The type OCP\IURLGenerator was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
20
21
use \OCA\Music\AppFramework\BusinessLayer\BusinessLayerException;
22
use \OCA\Music\AppFramework\Core\Logger;
23
use \OCA\Music\AppFramework\Utility\MethodAnnotationReader;
24
25
use \OCA\Music\BusinessLayer\AlbumBusinessLayer;
26
use \OCA\Music\BusinessLayer\ArtistBusinessLayer;
27
use \OCA\Music\BusinessLayer\Library;
28
use \OCA\Music\BusinessLayer\PlaylistBusinessLayer;
29
use \OCA\Music\BusinessLayer\TrackBusinessLayer;
30
31
use \OCA\Music\Db\SortBy;
32
33
use \OCA\Music\Http\FileResponse;
34
use \OCA\Music\Http\XMLResponse;
35
36
use \OCA\Music\Utility\CoverHelper;
37
use \OCA\Music\Utility\Util;
38
use OCA\Music\Middleware\SubsonicException;
39
40
class SubsonicController extends Controller {
41
	const API_VERSION = '1.4.0';
42
43
	private $albumBusinessLayer;
44
	private $artistBusinessLayer;
45
	private $playlistBusinessLayer;
46
	private $trackBusinessLayer;
47
	private $library;
48
	private $urlGenerator;
49
	private $rootFolder;
50
	private $l10n;
51
	private $coverHelper;
52
	private $logger;
53
	private $userId;
54
	private $format;
55
	private $callback;
56
57
	public function __construct($appname,
58
								IRequest $request,
59
								$l10n,
60
								IURLGenerator $urlGenerator,
61
								AlbumBusinessLayer $albumBusinessLayer,
62
								ArtistBusinessLayer $artistBusinessLayer,
63
								PlaylistBusinessLayer $playlistBusinessLayer,
64
								TrackBusinessLayer $trackBusinessLayer,
65
								Library $library,
66
								$rootFolder,
67
								CoverHelper $coverHelper,
68
								Logger $logger) {
69
		parent::__construct($appname, $request);
70
71
		$this->albumBusinessLayer = $albumBusinessLayer;
72
		$this->artistBusinessLayer = $artistBusinessLayer;
73
		$this->playlistBusinessLayer = $playlistBusinessLayer;
74
		$this->trackBusinessLayer = $trackBusinessLayer;
75
		$this->library = $library;
76
		$this->urlGenerator = $urlGenerator;
77
		$this->l10n = $l10n;
78
79
		// used to deliver actual media file
80
		$this->rootFolder = $rootFolder;
81
82
		$this->coverHelper = $coverHelper;
83
		$this->logger = $logger;
84
	}
85
86
	/**
87
	 * Called by the middleware once the user credentials have been checked
88
	 * @param string $userId
89
	 */
90
	public function setAuthenticatedUser($userId) {
91
		$this->userId = $userId;
92
	}
93
94
	/**
95
	 * @NoAdminRequired
96
	 * @PublicPage
97
	 * @NoCSRFRequired
98
	 * @SubsonicAPI
99
	 */
100
	public function handleRequest($method) {
101
		$this->format = $this->request->getParam('f', 'xml');
102
		$this->callback = $this->request->getParam('callback');
103
104
		if ($this->format != 'json' && $this->format != 'xml' && $this->format != 'jsonp') {
105
			throw new SubsonicException("Unsupported format {$this->format}", 0);
106
		}
107
108
		if ($this->format === 'jsonp' && $this->callback === null) {
109
			$this->format = 'json';
110
			throw new SubsonicException("Argument 'callback' is required with jsonp format", 10);
111
		}
112
113
		// Allow calling all methods with or without the postfix ".view"
114
		if (Util::endsWith($method, ".view")) {
115
			$method = \substr($method, 0, -\strlen(".view"));
116
		}
117
118
		// Allow calling any functions annotated to be part of the API, except for
119
		// recursive call back to this dispatcher function.
120
		if ($method !== 'handleRequest' && \method_exists($this, $method)) {
121
			$annotationReader = new MethodAnnotationReader($this, $method);
122
			if ($annotationReader->hasAnnotation('SubsonicAPI')) {
123
				return $this->$method();
124
			}
125
		}
126
127
		$this->logger->log("Request $method not supported", 'warn');
128
		return $this->subsonicErrorResponse(70, "Requested action $method is not supported");
129
	}
130
131
	/* -------------------------------------------------------------------------
132
	 * REST API methods
133
	 *------------------------------------------------------------------------*/
134
135
	/**
136
	 * @SubsonicAPI
137
	 */
138
	private function ping() {
139
		return $this->subsonicResponse([]);
140
	}
141
142
	/**
143
	 * @SubsonicAPI
144
	 */
145
	private function getLicense() {
146
		return $this->subsonicResponse([
147
			'license' => [
148
				'valid' => 'true',
149
				'email' => '',
150
				'licenseExpires' => 'never'
151
			]
152
		]);
153
	}
154
155
	/**
156
	 * @SubsonicAPI
157
	 */
158
	private function getMusicFolders() {
159
		// Only single root folder is supported
160
		return $this->subsonicResponse([
161
			'musicFolders' => ['musicFolder' => [
162
				['id' => 'root', 
163
				'name' => $this->l10n->t('Music')]
164
			]]
165
		]);
166
	}
167
168
	/**
169
	 * @SubsonicAPI
170
	 */
171
	private function getIndexes() {
172
		$artists = $this->artistBusinessLayer->findAllHavingAlbums($this->userId, SortBy::Name);
0 ignored issues
show
Bug introduced by
OCA\Music\Db\SortBy::Name of type integer is incompatible with the type OCA\Music\BusinessLayer\SortBy expected by parameter $sortBy of OCA\Music\BusinessLayer\...::findAllHavingAlbums(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

172
		$artists = $this->artistBusinessLayer->findAllHavingAlbums($this->userId, /** @scrutinizer ignore-type */ SortBy::Name);
Loading history...
173
174
		$indexes = [];
175
		foreach ($artists as $artist) {
176
			$indexes[$artist->getIndexingChar()][] = $this->artistAsChild($artist);
177
		}
178
179
		$result = [];
180
		foreach ($indexes as $indexChar => $bucketArtists) {
181
			$result[] = ['name' => $indexChar, 'artist' => $bucketArtists];
182
		}
183
184
		return $this->subsonicResponse(['indexes' => ['index' => $result]]);
185
	}
186
187
	/**
188
	 * @SubsonicAPI
189
	 */
190
	private function getMusicDirectory() {
191
		$id = $this->getRequiredParam('id');
192
193
		if (Util::startsWith($id, 'artist-')) {
194
			return $this->doGetMusicDirectoryForArtist($id);
195
		} else {
196
			return $this->doGetMusicDirectoryForAlbum($id);
197
		}
198
	}
199
200
	/**
201
	 * @SubsonicAPI
202
	 */
203
	private function getAlbumList() {
204
		$type = $this->getRequiredParam('type');
205
		$size = $this->request->getParam('size', 10);
206
		$size = \min($size, 500); // the API spec limits the maximum amount to 500
207
		// $offset = $this->request->getParam('offset', 0); parameter not supported for now
208
209
		$albums = [];
210
		if ($type == 'random') {
211
			$allAlbums = $this->albumBusinessLayer->findAll($this->userId);
212
			$albums = self::randomItems($allAlbums, $size);
213
		}
214
		// TODO: support 'newest', 'highest', 'frequent', 'recent'
215
216
		return $this->subsonicResponse(['albumList' =>
217
				['album' => \array_map([$this, 'albumAsChild'], $albums)]
218
		]);
219
	}
220
221
	/**
222
	 * @SubsonicAPI
223
	 */
224
	private function getRandomSongs() {
225
		$size = $this->request->getParam('size', 10);
226
		$size = \min($size, 500); // the API spec limits the maximum amount to 500
227
		// $genre = $this->request->getParam('genre'); not supported
228
		// $fromYear = $this->request->getParam('fromYear'); not supported
229
		// $toYear = $this->request->getParam('genre'); not supported
230
231
		$allTracks = $this->trackBusinessLayer->findAll($this->userId);
232
		$tracks = self::randomItems($allTracks, $size);
233
234
		return $this->subsonicResponse(['randomSongs' =>
235
				['song' => \array_map([$this, 'trackAsChild'], $tracks)]
236
		]);
237
	}
238
239
	/**
240
	 * @SubsonicAPI
241
	 */
242
	private function getCoverArt() {
243
		$id = $this->getRequiredParam('id');
244
		$userFolder = $this->rootFolder->getUserFolder($this->userId);
245
246
		try {
247
			$coverData = $this->coverHelper->getCover($id, $this->userId, $userFolder);
248
			if ($coverData !== null) {
0 ignored issues
show
introduced by
The condition $coverData !== null is always true.
Loading history...
249
				return new FileResponse($coverData);
250
			}
251
		} catch (BusinessLayerException $e) {
252
			return $this->subsonicErrorResponse(70, 'album not found');
253
		}
254
255
		return $this->subsonicErrorResponse(70, 'album has no cover');
256
	}
257
258
	/**
259
	 * @SubsonicAPI
260
	 */
261
	private function stream() {
262
		// We don't support transcaoding, so 'stream' and 'download' act identically
263
		return $this->download();
264
	}
265
266
	/**
267
	 * @SubsonicAPI
268
	 */
269
	private function download() {
270
		$id = $this->getRequiredParam('id');
271
		$trackId = \explode('-', $id)[1]; // get rid of 'track-' prefix
272
273
		try {
274
			$track = $this->trackBusinessLayer->find($trackId, $this->userId);
0 ignored issues
show
Bug introduced by
$trackId of type string is incompatible with the type integer expected by parameter $id of OCA\Music\AppFramework\B...r\BusinessLayer::find(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

274
			$track = $this->trackBusinessLayer->find(/** @scrutinizer ignore-type */ $trackId, $this->userId);
Loading history...
275
		} catch (BusinessLayerException $e) {
276
			return $this->subsonicErrorResponse(70, $e->getMessage());
277
		}
278
279
		$files = $this->rootFolder->getUserFolder($this->userId)->getById($track->getFileId());
280
281
		if (\count($files) === 1) {
282
			return new FileResponse($files[0]);
283
		} else {
284
			return $this->subsonicErrorResponse(70, 'file not found');
285
		}
286
	}
287
288
	/**
289
	 * @SubsonicAPI
290
	 */
291
	private function search2() {
292
		$query = $this->getRequiredParam('query');
293
		$artistCount = $this->request->getParam('artistCount', 20);
294
		$artistOffset = $this->request->getParam('artistOffset', 0);
295
		$albumCount = $this->request->getParam('albumCount', 20);
296
		$albumOffset = $this->request->getParam('albumOffset', 0);
297
		$songCount = $this->request->getParam('songCount', 20);
298
		$songOffset = $this->request->getParam('songOffset', 0);
299
300
		if (empty($query)) {
301
			throw new SubsonicException("The 'query' argument is mandatory", 10);
302
		}
303
304
		$artists = $this->artistBusinessLayer->findAllByName($query, $this->userId, true, $artistCount, $artistOffset);
305
		$albums = $this->albumBusinessLayer->findAllByName($query, $this->userId, true, $albumCount, $albumOffset);
306
		$tracks = $this->trackBusinessLayer->findAllByName($query, $this->userId, true, $songCount, $songOffset);
307
308
		$results = [];
309
		if (!empty($artists)) {
310
			$results['artist'] = \array_map([$this, 'artistAsChild'], $artists);
311
		}
312
		if (!empty($albums)) {
313
			$results['album'] = \array_map([$this, 'albumAsChild'], $albums);
314
		}
315
		if (!empty($tracks)) {
316
			$results['song'] = \array_map([$this, 'trackAsChild'], $tracks);
317
		}
318
319
		return $this->subsonicResponse(['searchResult2' => $results]);
320
	}
321
322
	/**
323
	 * @SubsonicAPI
324
	 */
325
	private function getPlaylists() {
326
		$playlists = $this->playlistBusinessLayer->findAll($this->userId);
327
328
		return $this->subsonicResponse(['playlists' =>
329
			['playlist' => \array_map([$this, 'playlistAsChild'], $playlists)]
330
		]);
331
	}
332
333
	/**
334
	 * @SubsonicAPI
335
	 */
336
	private function getPlaylist() {
337
		$id = $this->getRequiredParam('id');
338
		$playlist = $this->playlistBusinessLayer->find($id, $this->userId);
339
		$tracks = $this->playlistBusinessLayer->getPlaylistTracks($id, $this->userId);
340
341
		$playlistNode = $this->playlistAsChild($playlist);
342
		$playlistNode['entry'] = \array_map([$this, 'trackAsChild'], $tracks);
343
344
		return $this->subsonicResponse(['playlist' => $playlistNode]);
345
	}
346
347
	/* -------------------------------------------------------------------------
348
	 * Helper methods
349
	 *------------------------------------------------------------------------*/
350
351
	private function getRequiredParam($paramName) {
352
		$param = $this->request->getParam($paramName);
353
354
		if ($param === null) {
355
			throw new SubsonicException("Required parameter '$paramName' missing", 10);
356
		}
357
358
		return $param;
359
	}
360
361
	private function doGetMusicDirectoryForArtist($id) {
362
		$artistId = \explode('-', $id)[1]; // get rid of 'artist-' prefix
363
364
		$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
0 ignored issues
show
Bug introduced by
$artistId of type string is incompatible with the type integer expected by parameter $id of OCA\Music\AppFramework\B...r\BusinessLayer::find(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

364
		$artist = $this->artistBusinessLayer->find(/** @scrutinizer ignore-type */ $artistId, $this->userId);
Loading history...
365
		$artistName = $artist->getNameString($this->l10n);
366
		$albums = $this->albumBusinessLayer->findAllByAlbumArtist($artistId, $this->userId);
367
368
		$children = [];
369
		foreach ($albums as $album) {
370
			$children[] = $this->albumAsChild($album, $artistName);
371
		}
372
373
		return $this->subsonicResponse([
374
			'directory' => [
375
				'id' => $id,
376
				'parent' => 'root',
377
				'name' => $artistName,
378
				'child' => $children
379
			]
380
		]);
381
	}
382
383
	private function doGetMusicDirectoryForAlbum($id) {
384
		$albumId = \explode('-', $id)[1]; // get rid of 'album-' prefix
385
386
		$album = $this->albumBusinessLayer->find($albumId, $this->userId);
387
		$albumName = $album->getNameString($this->l10n);
388
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $this->userId);
389
390
		return $this->subsonicResponse([
391
			'directory' => [
392
				'id' => $id,
393
				'parent' => 'artist-' . $album->getAlbumArtistId(),
394
				'name' => $albumName,
395
				'child' => \array_map([$this, 'trackAsChild'], $tracks)
396
			]
397
		]);
398
	}
399
400
	private function artistAsChild($artist) {
401
		return [
402
			'name' => $artist->getNameString($this->l10n),
403
			'id' => 'artist-' . $artist->getId()
404
		];
405
	}
406
407
	private function albumAsChild($album, $artistName = null) {
408
		$artistId = $album->getAlbumArtistId();
409
410
		if (empty($artistName)) {
411
			$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
412
			$artistName = $artist->getNameString($this->l10n);
413
		}
414
415
		$result = [
416
			'id' => 'album-' . $album->getId(),
417
			'parent' => 'artist-' . $artistId,
418
			'title' => $album->getNameString($this->l10n),
419
			'artist' => $artistName,
420
			'isDir' => true
421
		];
422
423
		if (!empty($album->getCoverFileId())) {
424
			$result['coverArt'] = $album->getId();
425
		}
426
427
		return $result;
428
	}
429
430
	private function trackAsChild($track, $album = null, $albumName = null) {
431
		$albumId = $track->getAlbumId();
432
		if ($album == null) {
433
			$album = $this->albumBusinessLayer->find($albumId, $this->userId);
434
		}
435
		if (empty($albumName)) {
436
			$albumName = $album->getNameString($this->l10n);
437
		}
438
439
		$trackArtist = $this->artistBusinessLayer->find($track->getArtistId(), $this->userId);
440
		$result = [
441
			'id' => 'track-' . $track->getId(),
442
			'parent' => 'album-' . $albumId,
443
			'title' => $track->getTitle(),
444
			'artist' => $trackArtist->getNameString($this->l10n),
445
			'isDir' => false,
446
			'album' => $albumName,
447
			//'genre' => '',
448
			'year' => $track->getYear(),
449
			'size' => $track->getSize(),
450
			'contentType' => $track->getMimetype(),
451
			'suffix' => \end(\explode('.', $track->getFilename())),
452
			'duration' => $track->getLength() ?: 0,
453
			'bitRate' => \round($track->getBitrate()/1000) ?: 0, // convert bps to kbps
454
			//'path' => ''
455
		];
456
457
		if (!empty($album->getCoverFileId())) {
458
			$result['coverArt'] = $album->getId();
459
		}
460
461
		if ($track->getNumber() !== null) {
462
			$result['track'] = $track->getNumber();
463
		}
464
465
		return $result;
466
	}
467
468
	private function playlistAsChild($playlist) {
469
		return [
470
			'id' => $playlist->getId(),
471
			'name' => $playlist->getName(),
472
			'owner' => $this->userId,
473
			'public' => false,
474
			'songCount' => $playlist->getTrackCount(),
475
			// comment => '',
476
			// duration => '',
477
			// created => '',
478
			// coverArt => ''
479
		];
480
	}
481
482
	private static function randomItems($itemArray, $count) {
483
		$count = \min($count, \count($itemArray)); // can't return more than all items
484
		$indices = \array_rand($itemArray, $count);
485
		if ($count == 1) { // return type is not array when randomizing a single index
486
			$indices = [$indices];
487
		}
488
489
		$result = [];
490
		foreach ($indices as $index) {
491
			$result[] = $itemArray[$index];
492
		}
493
494
		return $result;
495
	}
496
497
	private function subsonicResponse($content, $status = 'ok') {
498
		$content['status'] = $status; 
499
		$content['version'] = self::API_VERSION;
500
		$responseData = ['subsonic-response' => $content];
501
502
		if ($this->format == 'json') {
503
			$response = new JSONResponse($responseData);
504
		} else if ($this->format == 'jsonp') {
505
			$responseData = \json_encode($responseData);
506
			$response = new DataDisplayResponse("{$this->callback}($responseData);");
507
			$response->addHeader('Content-Type', 'text/javascript; charset=UTF-8');
508
		} else {
509
			$response = new XMLResponse($responseData);
510
		}
511
512
		return $response;
513
	}
514
515
	public function subsonicErrorResponse($errorCode, $errorMessage) {
516
		return $this->subsonicResponse([
517
				'error' => [
518
					'code' => $errorCode,
519
					'message' => $errorMessage
520
				]
521
			], 'failed');
522
	}
523
}
524