Passed
Push — feature/329_Subsonic_API ( 793ffa...a9dee6 )
by Pauli
15:05
created

SubsonicController::playlistAsChild()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 6
nc 1
nop 1
dl 0
loc 7
rs 10
c 1
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\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 = self::ripIdPrefix($id); // 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
	 * @SubsonicAPI
349
	 */
350
	private function createPlaylist() {
351
		$name = $this->getRequiredParam('name');
352
		$songIds = $this->getRepeatedParam('songId');
353
		$songIds = \array_map('self::ripIdPrefix', $songIds);
354
355
		$playlist = $this->playlistBusinessLayer->create($name, $this->userId);
356
		$this->playlistBusinessLayer->addTracks($songIds, $playlist->getId(), $this->userId);
357
358
		return $this->subsonicResponse([]);
359
	}
360
361
	/* -------------------------------------------------------------------------
362
	 * Helper methods
363
	 *------------------------------------------------------------------------*/
364
365
	private function getRequiredParam($paramName) {
366
		$param = $this->request->getParam($paramName);
367
368
		if ($param === null) {
369
			throw new SubsonicException("Required parameter '$paramName' missing", 10);
370
		}
371
372
		return $param;
373
	}
374
375
	/** 
376
	 * Get values for parameter which may be present multiple times in the query
377
	 * string or POST data.
378
	 * @param string $paramName
379
	 * @return string[]
380
	 */
381
	private function getRepeatedParam($paramName) {
382
		// We can't use the IRequest object nor $_GET and $_POST to get the data
383
		// because all of these are based to the idea of unique parameter names.
384
		// If the same name is repeated, only the last value is saved. Hence, we
385
		// need to parse the raw data manually.
386
387
		// query string is always present (although it could be empty)
388
		$values = $this->parseRepeatedKeyValues($paramName, $_SERVER['QUERY_STRING']);
389
390
		// POST data is available if the method is POST
391
		if ($this->request->getMethod() == 'POST') {
392
			$values = \array_merge($values,
393
					$this->parseRepeatedKeyValues($paramName, file_get_contents('php://input')));
394
		}
395
396
		return $values;
397
	}
398
399
	/**
400
	 * Parse a string like "someKey=value1&someKey=value2&anotherKey=valueA&someKey=value3"
401
	 * and return an array of values for the given key
402
	 * @param string $key
403
	 * @param string $data
404
	 */
405
	private function parseRepeatedKeyValues($key, $data) {
406
		$result = [];
407
408
		$keyValuePairs = \explode('&', $data);
409
410
		foreach ($keyValuePairs as $pair) {
411
			$keyAndValue = \explode('=', $pair);
412
413
			if ($keyAndValue[0] == $key) {
414
				$result[] = $keyAndValue[1];
415
			}
416
		}
417
418
		return $result;
419
	}
420
421
	private function doGetMusicDirectoryForArtist($id) {
422
		$artistId = self::ripIdPrefix($id); // get rid of 'artist-' prefix
423
424
		$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

424
		$artist = $this->artistBusinessLayer->find(/** @scrutinizer ignore-type */ $artistId, $this->userId);
Loading history...
425
		$artistName = $artist->getNameString($this->l10n);
426
		$albums = $this->albumBusinessLayer->findAllByAlbumArtist($artistId, $this->userId);
427
428
		$children = [];
429
		foreach ($albums as $album) {
430
			$children[] = $this->albumAsChild($album, $artistName);
431
		}
432
433
		return $this->subsonicResponse([
434
			'directory' => [
435
				'id' => $id,
436
				'parent' => 'root',
437
				'name' => $artistName,
438
				'child' => $children
439
			]
440
		]);
441
	}
442
443
	private function doGetMusicDirectoryForAlbum($id) {
444
		$albumId = self::ripIdPrefix($id); // get rid of 'album-' prefix
445
446
		$album = $this->albumBusinessLayer->find($albumId, $this->userId);
447
		$albumName = $album->getNameString($this->l10n);
448
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $this->userId);
449
450
		return $this->subsonicResponse([
451
			'directory' => [
452
				'id' => $id,
453
				'parent' => 'artist-' . $album->getAlbumArtistId(),
454
				'name' => $albumName,
455
				'child' => \array_map([$this, 'trackAsChild'], $tracks)
456
			]
457
		]);
458
	}
459
460
	private function artistAsChild($artist) {
461
		return [
462
			'name' => $artist->getNameString($this->l10n),
463
			'id' => 'artist-' . $artist->getId()
464
		];
465
	}
466
467
	private function albumAsChild($album, $artistName = null) {
468
		$artistId = $album->getAlbumArtistId();
469
470
		if (empty($artistName)) {
471
			$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
472
			$artistName = $artist->getNameString($this->l10n);
473
		}
474
475
		$result = [
476
			'id' => 'album-' . $album->getId(),
477
			'parent' => 'artist-' . $artistId,
478
			'title' => $album->getNameString($this->l10n),
479
			'artist' => $artistName,
480
			'isDir' => true
481
		];
482
483
		if (!empty($album->getCoverFileId())) {
484
			$result['coverArt'] = $album->getId();
485
		}
486
487
		return $result;
488
	}
489
490
	private function trackAsChild($track, $album = null, $albumName = null) {
491
		$albumId = $track->getAlbumId();
492
		if ($album == null) {
493
			$album = $this->albumBusinessLayer->find($albumId, $this->userId);
494
		}
495
		if (empty($albumName)) {
496
			$albumName = $album->getNameString($this->l10n);
497
		}
498
499
		$trackArtist = $this->artistBusinessLayer->find($track->getArtistId(), $this->userId);
500
		$result = [
501
			'id' => 'track-' . $track->getId(),
502
			'parent' => 'album-' . $albumId,
503
			'title' => $track->getTitle(),
504
			'artist' => $trackArtist->getNameString($this->l10n),
505
			'isDir' => false,
506
			'album' => $albumName,
507
			//'genre' => '',
508
			'year' => $track->getYear(),
509
			'size' => $track->getSize(),
510
			'contentType' => $track->getMimetype(),
511
			'suffix' => \end(\explode('.', $track->getFilename())),
512
			'duration' => $track->getLength() ?: 0,
513
			'bitRate' => \round($track->getBitrate()/1000) ?: 0, // convert bps to kbps
514
			//'path' => ''
515
		];
516
517
		if (!empty($album->getCoverFileId())) {
518
			$result['coverArt'] = $album->getId();
519
		}
520
521
		if ($track->getNumber() !== null) {
522
			$result['track'] = $track->getNumber();
523
		}
524
525
		return $result;
526
	}
527
528
	private function playlistAsChild($playlist) {
529
		return [
530
			'id' => $playlist->getId(),
531
			'name' => $playlist->getName(),
532
			'owner' => $this->userId,
533
			'public' => false,
534
			'songCount' => $playlist->getTrackCount(),
535
			// comment => '',
536
			// duration => '',
537
			// created => '',
538
			// coverArt => ''
539
		];
540
	}
541
542
	/**
543
	 * Given a prefixed ID like 'artist-123' or 'track-45', return just the numeric part.
544
	 * @param string $id
545
	 * @return string
546
	 */
547
	private static function ripIdPrefix($id) {
548
		return \explode('-', $id)[1];
549
	}
550
551
	private static function randomItems($itemArray, $count) {
552
		$count = \min($count, \count($itemArray)); // can't return more than all items
553
		$indices = \array_rand($itemArray, $count);
554
		if ($count == 1) { // return type is not array when randomizing a single index
555
			$indices = [$indices];
556
		}
557
558
		$result = [];
559
		foreach ($indices as $index) {
560
			$result[] = $itemArray[$index];
561
		}
562
563
		return $result;
564
	}
565
566
	private function subsonicResponse($content, $status = 'ok') {
567
		$content['status'] = $status; 
568
		$content['version'] = self::API_VERSION;
569
		$responseData = ['subsonic-response' => $content];
570
571
		if ($this->format == 'json') {
572
			$response = new JSONResponse($responseData);
573
		} else if ($this->format == 'jsonp') {
574
			$responseData = \json_encode($responseData);
575
			$response = new DataDisplayResponse("{$this->callback}($responseData);");
576
			$response->addHeader('Content-Type', 'text/javascript; charset=UTF-8');
577
		} else {
578
			$response = new XMLResponse($responseData);
579
		}
580
581
		return $response;
582
	}
583
584
	public function subsonicErrorResponse($errorCode, $errorMessage) {
585
		return $this->subsonicResponse([
586
				'error' => [
587
					'code' => $errorCode,
588
					'message' => $errorMessage
589
				]
590
			], 'failed');
591
	}
592
}
593