Passed
Push — feature/329_Subsonic_API ( d9d298 )
by Pauli
10:35
created

SubsonicController::handleRequest()   B

Complexity

Conditions 7
Paths 7

Size

Total Lines 22
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 7
eloc 11
nc 7
nop 1
dl 0
loc 22
rs 8.8333
c 2
b 0
f 0
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\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...
17
use \OCP\AppFramework\Http\TemplateResponse;
0 ignored issues
show
Bug introduced by
The type OCP\AppFramework\Http\TemplateResponse 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\BusinessLayer;
22
use \OCA\Music\AppFramework\BusinessLayer\BusinessLayerException;
23
use \OCA\Music\AppFramework\Core\Logger;
24
use \OCA\Music\AppFramework\Utility\MethodAnnotationReader;
25
26
use \OCA\Music\BusinessLayer\AlbumBusinessLayer;
27
use \OCA\Music\BusinessLayer\ArtistBusinessLayer;
28
use \OCA\Music\BusinessLayer\Library;
29
use \OCA\Music\BusinessLayer\PlaylistBusinessLayer;
30
use \OCA\Music\BusinessLayer\TrackBusinessLayer;
31
32
use \OCA\Music\Db\SortBy;
33
34
use \OCA\Music\Http\ErrorResponse;
35
use \OCA\Music\Http\FileResponse;
36
use \OCA\Music\Http\XMLResponse;
37
38
use \OCA\Music\Utility\CoverHelper;
39
use \OCA\Music\Utility\Util;
40
use OCA\Music\Middleware\SubsonicException;
41
42
class SubsonicController extends Controller {
43
	const API_VERSION = '1.4.0';
44
45
	private $albumBusinessLayer;
46
	private $artistBusinessLayer;
47
	private $playlistBusinessLayer;
48
	private $trackBusinessLayer;
49
	private $library;
50
	private $urlGenerator;
51
	private $rootFolder;
52
	private $l10n;
53
	private $coverHelper;
54
	private $logger;
55
	private $userId;
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');
0 ignored issues
show
Bug Best Practice introduced by
The property format does not exist. Although not strictly required by PHP, it is generally a best practice to declare properties explicitly.
Loading history...
102
		if ($this->format != 'json' && $this->format != 'xml') {
103
			throw new SubsonicException("Unsupported format {$this->format}", 0);
104
		}
105
106
		// Allow calling all methods with or without the postfix ".view"
107
		if (Util::endsWith($method, ".view")) {
108
			$method = \substr($method, 0, -\strlen(".view"));
109
		}
110
111
		// Allow calling any functions annotated to be part of the API, except for
112
		// recursive call back to this dispatcher function.
113
		if ($method !== 'handleRequest' && \method_exists($this, $method)) {
114
			$annotationReader = new MethodAnnotationReader($this, $method);
115
			if ($annotationReader->hasAnnotation('SubsonicAPI')) {
116
				return $this->$method();
117
			}
118
		}
119
120
		$this->logger->log("Request $method not supported", 'warn');
121
		return $this->subsonicErrorResponse(70, "Requested action $method is not supported");
122
	}
123
124
	/* -------------------------------------------------------------------------
125
	 * REST API methods
126
	 *------------------------------------------------------------------------*/
127
128
	/**
129
	 * @SubsonicAPI
130
	 */
131
	private function ping() {
132
		return $this->subsonicResponse([]);
133
	}
134
135
	/**
136
	 * @SubsonicAPI
137
	 */
138
	private function getLicense() {
139
		return $this->subsonicResponse([
140
			'license' => [
141
				'valid' => 'true',
142
				'email' => '',
143
				'licenseExpires' => 'never'
144
			]
145
		]);
146
	}
147
148
	/**
149
	 * @SubsonicAPI
150
	 */
151
	private function getMusicFolders() {
152
		// Only single root folder is supported
153
		return $this->subsonicResponse([
154
			'musicFolders' => ['musicFolder' => [
155
				['id' => 'root', 
156
				'name' => $this->l10n->t('Music')]
157
			]]
158
		]);
159
	}
160
161
	/**
162
	 * @SubsonicAPI
163
	 */
164
	private function getIndexes() {
165
		$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

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

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

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