Passed
Push — feature/329_Subsonic_API ( 9783d2...87cb9d )
by Pauli
11:09
created

SubsonicController::getAlbumList2()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 11
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 3
c 1
b 0
f 0
nc 1
nop 0
dl 0
loc 11
rs 10
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\Files\File;
0 ignored issues
show
Bug introduced by
The type OCP\Files\File 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\Files\Folder;
0 ignored issues
show
Bug introduced by
The type OCP\Files\Folder 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
use \OCP\Files\IRootFolder;
0 ignored issues
show
Bug introduced by
The type OCP\Files\IRootFolder 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...
21
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...
22
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...
23
24
use \OCA\Music\AppFramework\BusinessLayer\BusinessLayerException;
25
use \OCA\Music\AppFramework\Core\Logger;
26
use \OCA\Music\AppFramework\Utility\MethodAnnotationReader;
27
28
use \OCA\Music\BusinessLayer\AlbumBusinessLayer;
29
use \OCA\Music\BusinessLayer\ArtistBusinessLayer;
30
use \OCA\Music\BusinessLayer\Library;
31
use \OCA\Music\BusinessLayer\PlaylistBusinessLayer;
32
use \OCA\Music\BusinessLayer\TrackBusinessLayer;
33
34
use \OCA\Music\Db\SortBy;
35
36
use \OCA\Music\Http\FileResponse;
37
use \OCA\Music\Http\XMLResponse;
38
39
use \OCA\Music\Middleware\SubsonicException;
40
41
use \OCA\Music\Utility\CoverHelper;
42
use \OCA\Music\Utility\UserMusicFolder;
43
use \OCA\Music\Utility\Util;
44
45
class SubsonicController extends Controller {
46
	const API_VERSION = '1.4.0';
47
48
	private $albumBusinessLayer;
49
	private $artistBusinessLayer;
50
	private $playlistBusinessLayer;
51
	private $trackBusinessLayer;
52
	private $library;
53
	private $urlGenerator;
54
	private $rootFolder;
55
	private $userMusicFolder;
56
	private $l10n;
57
	private $coverHelper;
58
	private $logger;
59
	private $userId;
60
	private $format;
61
	private $callback;
62
63
	public function __construct($appname,
64
								IRequest $request,
65
								$l10n,
66
								IURLGenerator $urlGenerator,
67
								AlbumBusinessLayer $albumBusinessLayer,
68
								ArtistBusinessLayer $artistBusinessLayer,
69
								PlaylistBusinessLayer $playlistBusinessLayer,
70
								TrackBusinessLayer $trackBusinessLayer,
71
								Library $library,
72
								IRootFolder $rootFolder,
73
								UserMusicFolder $userMusicFolder,
74
								CoverHelper $coverHelper,
75
								Logger $logger) {
76
		parent::__construct($appname, $request);
77
78
		$this->albumBusinessLayer = $albumBusinessLayer;
79
		$this->artistBusinessLayer = $artistBusinessLayer;
80
		$this->playlistBusinessLayer = $playlistBusinessLayer;
81
		$this->trackBusinessLayer = $trackBusinessLayer;
82
		$this->library = $library;
83
		$this->urlGenerator = $urlGenerator;
84
		$this->l10n = $l10n;
85
		$this->rootFolder = $rootFolder;
86
		$this->userMusicFolder = $userMusicFolder;
87
		$this->coverHelper = $coverHelper;
88
		$this->logger = $logger;
89
	}
90
91
	/**
92
	 * Called by the middleware once the user credentials have been checked
93
	 * @param string $userId
94
	 */
95
	public function setAuthenticatedUser($userId) {
96
		$this->userId = $userId;
97
	}
98
99
	/**
100
	 * @NoAdminRequired
101
	 * @PublicPage
102
	 * @NoCSRFRequired
103
	 */
104
	public function handleRequest($method) {
105
		$this->format = $this->request->getParam('f', 'xml');
106
		$this->callback = $this->request->getParam('callback');
107
108
		if (!\in_array($this->format, ['json', 'xml', 'jsonp'])) {
109
			throw new SubsonicException("Unsupported format {$this->format}", 0);
110
		}
111
112
		if ($this->format === 'jsonp' && empty($this->callback)) {
113
			$this->format = 'json';
114
			throw new SubsonicException("Argument 'callback' is required with jsonp format", 10);
115
		}
116
117
		// Allow calling all methods with or without the postfix ".view"
118
		if (Util::endsWith($method, ".view")) {
119
			$method = \substr($method, 0, -\strlen(".view"));
120
		}
121
122
		// Allow calling any functions annotated to be part of the API
123
		if (\method_exists($this, $method)) {
124
			$annotationReader = new MethodAnnotationReader($this, $method);
125
			if ($annotationReader->hasAnnotation('SubsonicAPI')) {
126
				return $this->$method();
127
			}
128
		}
129
130
		$this->logger->log("Request $method not supported", 'warn');
131
		return $this->subsonicErrorResponse(70, "Requested action $method is not supported");
132
	}
133
134
	/* -------------------------------------------------------------------------
135
	 * REST API methods
136
	 *------------------------------------------------------------------------*/
137
138
	/**
139
	 * @SubsonicAPI
140
	 */
141
	private function ping() {
142
		return $this->subsonicResponse([]);
143
	}
144
145
	/**
146
	 * @SubsonicAPI
147
	 */
148
	private function getLicense() {
149
		return $this->subsonicResponse([
150
			'license' => [
151
				'valid' => 'true',
152
				'email' => '',
153
				'licenseExpires' => 'never'
154
			]
155
		]);
156
	}
157
158
	/**
159
	 * @SubsonicAPI
160
	 */
161
	private function getMusicFolders() {
162
		// Only single root folder is supported
163
		return $this->subsonicResponse([
164
			'musicFolders' => ['musicFolder' => [
165
				['id' => 'artists', 'name' => $this->l10n->t('Artists')],
166
				['id' => 'folders', 'name' => $this->l10n->t('Folders')]
167
			]]
168
		]);
169
	}
170
171
	/**
172
	 * @SubsonicAPI
173
	 */
174
	private function getIndexes() {
175
		$id = $this->request->getParam('musicFolderId');
176
177
		if ($id === 'folders') {
178
			return $this->getIndexesForFolders();
179
		} else {
180
			return $this->getIndexesForArtists();
181
		}
182
	}
183
184
	/**
185
	 * @SubsonicAPI
186
	 */
187
	private function getMusicDirectory() {
188
		$id = $this->getRequiredParam('id');
189
190
		if (Util::startsWith($id, 'folder-')) {
191
			return $this->getMusicDirectoryForFolder($id);
192
		} elseif (Util::startsWith($id, 'artist-')) {
193
			return $this->getMusicDirectoryForArtist($id);
194
		} else {
195
			return $this->getMusicDirectoryForAlbum($id);
196
		}
197
	}
198
199
	/**
200
	 * @SubsonicAPI
201
	 */
202
	private function getAlbumList() {
203
		$albums = $this->albumsForGetAlbumList();
204
		return $this->subsonicResponse(['albumList' =>
205
				['album' => \array_map([$this, 'albumAsChild'], $albums)]
206
		]);
207
	}
208
209
	/**
210
	 * @SubsonicAPI
211
	 */
212
	private function getAlbumList2() {
213
		/*
214
		 *  According to the API specification, the difference between this and getAlbumList
215
		 * should be that this function would organize albums according the metadata while
216
		 * getAlbumList would organize them by folders. However, we organize by metadata
217
		 * also in getAlbumList, because that's more natural for the Music app and many/most
218
		 * clients do not support getAlbumList2.
219
		 */
220
		$albums = $this->albumsForGetAlbumList();
221
		return $this->subsonicResponse(['albumList2' =>
222
				['album' => \array_map([$this, 'albumToNewApi'], $albums)]
223
		]);
224
	}
225
226
	/**
227
	 * @SubsonicAPI
228
	 */
229
	private function getAlbum() {
230
		$id = $this->getRequiredParam('id');
231
		$albumId = self::ripIdPrefix($id); // get rid of 'album-' prefix
232
233
		$album = $this->albumBusinessLayer->find($albumId, $this->userId);
234
		$albumName = $album->getNameString($this->l10n);
0 ignored issues
show
Unused Code introduced by
The assignment to $albumName is dead and can be removed.
Loading history...
235
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $this->userId);
236
237
		$albumNode = $this->albumToNewApi($album);
238
		$albumNode['song'] = \array_map([$this, 'trackToNewApi'], $tracks);
239
240
		return $this->subsonicResponse(['album' => $albumNode]);
241
	}
242
243
	/**
244
	 * @SubsonicAPI
245
	 */
246
	private function getRandomSongs() {
247
		$size = $this->request->getParam('size', 10);
248
		$size = \min($size, 500); // the API spec limits the maximum amount to 500
249
		// $genre = $this->request->getParam('genre'); not supported
250
		// $fromYear = $this->request->getParam('fromYear'); not supported
251
		// $toYear = $this->request->getParam('genre'); not supported
252
253
		$allTracks = $this->trackBusinessLayer->findAll($this->userId);
254
		$tracks = self::randomItems($allTracks, $size);
255
256
		return $this->subsonicResponse(['randomSongs' =>
257
				['song' => \array_map([$this, 'trackAsChild'], $tracks)]
258
		]);
259
	}
260
261
	/**
262
	 * @SubsonicAPI
263
	 */
264
	private function getCoverArt() {
265
		$id = $this->getRequiredParam('id');
266
		$userFolder = $this->rootFolder->getUserFolder($this->userId);
267
		$coverData = $this->coverHelper->getCover($id, $this->userId, $userFolder);
268
269
		if ($coverData !== null) {
0 ignored issues
show
introduced by
The condition $coverData !== null is always true.
Loading history...
270
			return new FileResponse($coverData);
271
		}
272
273
		return $this->subsonicErrorResponse(70, 'album has no cover');
274
	}
275
276
	/**
277
	 * @SubsonicAPI
278
	 */
279
	private function stream() {
280
		// We don't support transcaoding, so 'stream' and 'download' act identically
281
		return $this->download();
282
	}
283
284
	/**
285
	 * @SubsonicAPI
286
	 */
287
	private function download() {
288
		$id = $this->getRequiredParam('id');
289
		$trackId = self::ripIdPrefix($id); // get rid of 'track-' prefix
290
291
		$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

291
		$track = $this->trackBusinessLayer->find(/** @scrutinizer ignore-type */ $trackId, $this->userId);
Loading history...
292
		$file = $this->getFilesystemNode($track->getFileId());
293
294
		if ($file instanceof File) {
295
			return new FileResponse($file);
296
		} else {
297
			return $this->subsonicErrorResponse(70, 'file not found');
298
		}
299
	}
300
301
	/**
302
	 * @SubsonicAPI
303
	 */
304
	private function search2() {
305
		$query = $this->getRequiredParam('query');
306
		$artistCount = $this->request->getParam('artistCount', 20);
307
		$artistOffset = $this->request->getParam('artistOffset', 0);
308
		$albumCount = $this->request->getParam('albumCount', 20);
309
		$albumOffset = $this->request->getParam('albumOffset', 0);
310
		$songCount = $this->request->getParam('songCount', 20);
311
		$songOffset = $this->request->getParam('songOffset', 0);
312
313
		if (empty($query)) {
314
			throw new SubsonicException("The 'query' argument is mandatory", 10);
315
		}
316
317
		$artists = $this->artistBusinessLayer->findAllByName($query, $this->userId, true, $artistCount, $artistOffset);
318
		$albums = $this->albumBusinessLayer->findAllByName($query, $this->userId, true, $albumCount, $albumOffset);
319
		$tracks = $this->trackBusinessLayer->findAllByName($query, $this->userId, true, $songCount, $songOffset);
320
321
		$results = [];
322
		if (!empty($artists)) {
323
			$results['artist'] = \array_map([$this, 'artistAsChild'], $artists);
324
		}
325
		if (!empty($albums)) {
326
			$results['album'] = \array_map([$this, 'albumAsChild'], $albums);
327
		}
328
		if (!empty($tracks)) {
329
			$results['song'] = \array_map([$this, 'trackAsChild'], $tracks);
330
		}
331
332
		return $this->subsonicResponse(['searchResult2' => $results]);
333
	}
334
335
	/**
336
	 * @SubsonicAPI
337
	 */
338
	private function getPlaylists() {
339
		$playlists = $this->playlistBusinessLayer->findAll($this->userId);
340
341
		return $this->subsonicResponse(['playlists' =>
342
			['playlist' => \array_map([$this, 'playlistAsChild'], $playlists)]
343
		]);
344
	}
345
346
	/**
347
	 * @SubsonicAPI
348
	 */
349
	private function getPlaylist() {
350
		$id = $this->getRequiredParam('id');
351
		$playlist = $this->playlistBusinessLayer->find($id, $this->userId);
352
		$tracks = $this->playlistBusinessLayer->getPlaylistTracks($id, $this->userId);
353
354
		$playlistNode = $this->playlistAsChild($playlist);
355
		$playlistNode['entry'] = \array_map([$this, 'trackAsChild'], $tracks);
356
357
		return $this->subsonicResponse(['playlist' => $playlistNode]);
358
	}
359
360
	/**
361
	 * @SubsonicAPI
362
	 */
363
	private function createPlaylist() {
364
		$name = $this->getRequiredParam('name');
365
		$songIds = $this->getRepeatedParam('songId');
366
		$songIds = \array_map('self::ripIdPrefix', $songIds);
367
368
		$playlist = $this->playlistBusinessLayer->create($name, $this->userId);
369
		$this->playlistBusinessLayer->addTracks($songIds, $playlist->getId(), $this->userId);
370
371
		return $this->subsonicResponse([]);
372
	}
373
374
	/* -------------------------------------------------------------------------
375
	 * Helper methods
376
	 *------------------------------------------------------------------------*/
377
378
	private function getRequiredParam($paramName) {
379
		$param = $this->request->getParam($paramName);
380
381
		if ($param === null) {
382
			throw new SubsonicException("Required parameter '$paramName' missing", 10);
383
		}
384
385
		return $param;
386
	}
387
388
	/** 
389
	 * Get values for parameter which may be present multiple times in the query
390
	 * string or POST data.
391
	 * @param string $paramName
392
	 * @return string[]
393
	 */
394
	private function getRepeatedParam($paramName) {
395
		// We can't use the IRequest object nor $_GET and $_POST to get the data
396
		// because all of these are based to the idea of unique parameter names.
397
		// If the same name is repeated, only the last value is saved. Hence, we
398
		// need to parse the raw data manually.
399
400
		// query string is always present (although it could be empty)
401
		$values = $this->parseRepeatedKeyValues($paramName, $_SERVER['QUERY_STRING']);
402
403
		// POST data is available if the method is POST
404
		if ($this->request->getMethod() == 'POST') {
405
			$values = \array_merge($values,
406
					$this->parseRepeatedKeyValues($paramName, file_get_contents('php://input')));
407
		}
408
409
		return $values;
410
	}
411
412
	/**
413
	 * Parse a string like "someKey=value1&someKey=value2&anotherKey=valueA&someKey=value3"
414
	 * and return an array of values for the given key
415
	 * @param string $key
416
	 * @param string $data
417
	 */
418
	private function parseRepeatedKeyValues($key, $data) {
419
		$result = [];
420
421
		$keyValuePairs = \explode('&', $data);
422
423
		foreach ($keyValuePairs as $pair) {
424
			$keyAndValue = \explode('=', $pair);
425
426
			if ($keyAndValue[0] == $key) {
427
				$result[] = $keyAndValue[1];
428
			}
429
		}
430
431
		return $result;
432
	}
433
434
	private function getFilesystemNode($id) {
435
		$nodes = $this->rootFolder->getUserFolder($this->userId)->getById($id);
436
437
		if (\count($nodes) != 1) {
438
			throw new SubsonicException('file not found', 70);
439
		}
440
441
		return $nodes[0];
442
	}
443
444
	private function getIndexesForFolders() {
445
		$rootFolder = $this->userMusicFolder->getFolder($this->userId);
446
	
447
		return $this->subsonicResponse(['indexes' => ['index' => [
448
				['name' => '*',
449
				'artist' => [['id' => 'folder-' . $rootFolder->getId(), 'name' => $rootFolder->getName()]]]
450
				]]]);
451
	}
452
	
453
	private function getMusicDirectoryForFolder($id) {
454
		$folderId = self::ripIdPrefix($id); // get rid of 'folder-' prefix
455
		$folder = $this->getFilesystemNode($folderId);
456
457
		if (!($folder instanceof Folder)) {
458
			throw new SubsonicException("$id is not a valid folder", 70);
459
		}
460
461
		$nodes = $folder->getDirectoryListing();
462
		$subFolders = \array_filter($nodes, function ($n) {
463
			return $n instanceof Folder;
464
		});
465
		$tracks = $this->trackBusinessLayer->findAllByFolder($folderId, $this->userId);
0 ignored issues
show
Bug introduced by
$folderId of type string is incompatible with the type integer expected by parameter $folderId of OCA\Music\BusinessLayer\...ayer::findAllByFolder(). ( Ignorable by Annotation )

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

465
		$tracks = $this->trackBusinessLayer->findAllByFolder(/** @scrutinizer ignore-type */ $folderId, $this->userId);
Loading history...
466
467
		$children = \array_merge(
468
			\array_map([$this, 'folderAsChild'], $subFolders),
469
			\array_map([$this, 'trackAsChild'], $tracks)
470
		);
471
472
		return $this->subsonicResponse([
473
			'directory' => [
474
				'id' => $id,
475
				'parent' => 'folder-' . $folder->getParent()->getId(),
476
				'name' => $folder->getName(),
477
				'child' => $children
478
			]
479
		]);
480
	}
481
482
	private function getIndexesForArtists() {
483
		$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

483
		$artists = $this->artistBusinessLayer->findAllHavingAlbums($this->userId, /** @scrutinizer ignore-type */ SortBy::Name);
Loading history...
484
	
485
		$indexes = [];
486
		foreach ($artists as $artist) {
487
			$indexes[$artist->getIndexingChar()][] = $this->artistAsChild($artist);
488
		}
489
	
490
		$result = [];
491
		foreach ($indexes as $indexChar => $bucketArtists) {
492
			$result[] = ['name' => $indexChar, 'artist' => $bucketArtists];
493
		}
494
	
495
		return $this->subsonicResponse(['indexes' => ['index' => $result]]);
496
	}
497
498
	private function getMusicDirectoryForArtist($id) {
499
		$artistId = self::ripIdPrefix($id); // get rid of 'artist-' prefix
500
501
		$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

501
		$artist = $this->artistBusinessLayer->find(/** @scrutinizer ignore-type */ $artistId, $this->userId);
Loading history...
502
		$artistName = $artist->getNameString($this->l10n);
503
		$albums = $this->albumBusinessLayer->findAllByAlbumArtist($artistId, $this->userId);
504
505
		$children = [];
506
		foreach ($albums as $album) {
507
			$children[] = $this->albumAsChild($album, $artistName);
508
		}
509
510
		return $this->subsonicResponse([
511
			'directory' => [
512
				'id' => $id,
513
				'parent' => 'artists',
514
				'name' => $artistName,
515
				'child' => $children
516
			]
517
		]);
518
	}
519
520
	private function getMusicDirectoryForAlbum($id) {
521
		$albumId = self::ripIdPrefix($id); // get rid of 'album-' prefix
522
523
		$album = $this->albumBusinessLayer->find($albumId, $this->userId);
524
		$albumName = $album->getNameString($this->l10n);
525
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $this->userId);
526
527
		return $this->subsonicResponse([
528
			'directory' => [
529
				'id' => $id,
530
				'parent' => 'artist-' . $album->getAlbumArtistId(),
531
				'name' => $albumName,
532
				'child' => \array_map([$this, 'trackAsChild'], $tracks)
533
			]
534
		]);
535
	}
536
537
	private function folderAsChild($folder) {
538
		return [
539
			'id' => 'folder-' . $folder->getId(),
540
			'title' => $folder->getName(),
541
			'isDir' => true
542
		];
543
	}
544
	private function artistAsChild($artist) {
545
		return [
546
			'name' => $artist->getNameString($this->l10n),
547
			'id' => 'artist-' . $artist->getId()
548
		];
549
	}
550
551
	private function albumAsChild($album, $artistName = null) {
552
		$artistId = $album->getAlbumArtistId();
553
554
		if (empty($artistName)) {
555
			$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
556
			$artistName = $artist->getNameString($this->l10n);
557
		}
558
559
		$result = [
560
			'id' => 'album-' . $album->getId(),
561
			'parent' => 'artist-' . $artistId,
562
			'title' => $album->getNameString($this->l10n),
563
			'artist' => $artistName,
564
			'isDir' => true
565
		];
566
567
		if (!empty($album->getCoverFileId())) {
568
			$result['coverArt'] = $album->getId();
569
		}
570
571
		return $result;
572
	}
573
574
	private function albumToNewApi($album, $artistName = null) {
575
		$artistId = $album->getAlbumArtistId();
576
577
		if (empty($artistName)) {
578
			$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
579
			$artistName = $artist->getNameString($this->l10n);
580
		}
581
582
		$result = [
583
			'id' => 'album-' . $album->getId(),
584
			'artistId' => 'artist-' . $artistId,
585
			'name' => $album->getNameString($this->l10n),
586
			'artist' => $artistName,
587
			'songCount' => $this->trackBusinessLayer->countByAlbum($album->getId()),
588
			//'duration' => 0
589
		];
590
	
591
		if (!empty($album->getCoverFileId())) {
592
			$result['coverArt'] = $album->getId();
593
		}
594
	
595
		return $result;
596
	}
597
598
	private function trackAsChild($track, $album = null, $albumName = null) {
599
		$albumId = $track->getAlbumId();
600
		if ($album == null) {
601
			$album = $this->albumBusinessLayer->find($albumId, $this->userId);
602
		}
603
		if (empty($albumName)) {
604
			$albumName = $album->getNameString($this->l10n);
605
		}
606
607
		$trackArtist = $this->artistBusinessLayer->find($track->getArtistId(), $this->userId);
608
		$result = [
609
			'id' => 'track-' . $track->getId(),
610
			'parent' => 'album-' . $albumId,
611
			'title' => $track->getTitle(),
612
			'artist' => $trackArtist->getNameString($this->l10n),
613
			'isDir' => false,
614
			'album' => $albumName,
615
			//'genre' => '',
616
			'year' => $track->getYear(),
617
			'size' => $track->getSize(),
618
			'contentType' => $track->getMimetype(),
619
			'suffix' => \end(\explode('.', $track->getFilename())),
620
			'duration' => $track->getLength() ?: 0,
621
			'bitRate' => \round($track->getBitrate()/1000) ?: 0, // convert bps to kbps
622
			//'path' => ''
623
		];
624
625
		if (!empty($album->getCoverFileId())) {
626
			$result['coverArt'] = $album->getId();
627
		}
628
629
		if ($track->getNumber() !== null) {
630
			$result['track'] = $track->getNumber();
631
		}
632
633
		return $result;
634
	}
635
636
	private function trackToNewApi($track, $album = null, $albumName = null) {
637
		$albumId = $track->getAlbumId();
638
		if ($album == null) {
639
			$album = $this->albumBusinessLayer->find($albumId, $this->userId);
640
		}
641
		if (empty($albumName)) {
642
			$albumName = $album->getNameString($this->l10n);
643
		}
644
645
		$trackArtist = $this->artistBusinessLayer->find($track->getArtistId(), $this->userId);
646
		$result = [
647
			'id' => 'track-' . $track->getId(),
648
			'parent' => 'album-' . $albumId,
649
			'title' => $track->getTitle(),
650
			'artist' => $trackArtist->getNameString($this->l10n),
651
			'isDir' => false,
652
			'album' => $albumName,
653
			//'genre' => '',
654
			'year' => $track->getYear(),
655
			'size' => $track->getSize(),
656
			'contentType' => $track->getMimetype(),
657
			'suffix' => \end(\explode('.', $track->getFilename())),
658
			'duration' => $track->getLength() ?: 0,
659
			'bitRate' => \round($track->getBitrate()/1000) ?: 0, // convert bps to kbps
660
			//'path' => '',
661
			'isVideo' => false,
662
			'albumId' => 'album-' . $albumId,
663
			'artistId' => 'artist-' . $track->getArtistId(),
664
			'type' => 'music',
665
		];
666
667
		if (!empty($album->getCoverFileId())) {
668
			$result['coverArt'] = $album->getId();
669
		}
670
671
		if ($track->getNumber() !== null) {
672
			$result['track'] = $track->getNumber();
673
		}
674
675
		return $result;
676
	}
677
678
	private function playlistAsChild($playlist) {
679
		return [
680
			'id' => $playlist->getId(),
681
			'name' => $playlist->getName(),
682
			'owner' => $this->userId,
683
			'public' => false,
684
			'songCount' => $playlist->getTrackCount(),
685
			// comment => '',
686
			// duration => '',
687
			// created => '',
688
			// coverArt => ''
689
		];
690
	}
691
692
	/**
693
	 * Common logic for getAlbumList and getAlbumList2
694
	 * @return Album[]
695
	 */
696
	private function albumsForGetAlbumList() {
697
		$type = $this->getRequiredParam('type');
698
		$size = $this->request->getParam('size', 10);
699
		$size = \min($size, 500); // the API spec limits the maximum amount to 500
700
		$offset = $this->request->getParam('offset', 0);
701
702
		$albums = [];
703
704
		switch ($type) {
705
			case 'random':
706
				$allAlbums = $this->albumBusinessLayer->findAll($this->userId);
707
				$albums = self::randomItems($allAlbums, $size);
708
				// Note: offset is not supported on this type
709
				break;
710
			case 'alphabeticalByName':
711
				$albums = $this->albumBusinessLayer->findAll($this->userId, SortBy::Name, $size, $offset);
712
				break;
713
			case 'alphabeticalByArtist':
714
			case 'newest':
715
			case 'highest':
716
			case 'frequent':
717
			case 'recent':
718
			case 'starred':
719
			case 'byYear':
720
			case 'byGenre':
721
			default:
722
				$this->logger->log("Album list type '$type' is not supported", 'warn');
723
				break;
724
		}
725
726
		return $albums;
727
	}
728
729
	/**
730
	 * Given a prefixed ID like 'artist-123' or 'track-45', return just the numeric part.
731
	 * @param string $id
732
	 * @return string
733
	 */
734
	private static function ripIdPrefix($id) {
735
		return \explode('-', $id)[1];
736
	}
737
738
	private static function randomItems($itemArray, $count) {
739
		$count = \min($count, \count($itemArray)); // can't return more than all items
740
		$indices = \array_rand($itemArray, $count);
741
		if ($count == 1) { // return type is not array when randomizing a single index
742
			$indices = [$indices];
743
		}
744
745
		$result = [];
746
		foreach ($indices as $index) {
747
			$result[] = $itemArray[$index];
748
		}
749
750
		return $result;
751
	}
752
753
	private function subsonicResponse($content, $status = 'ok') {
754
		$content['status'] = $status; 
755
		$content['version'] = self::API_VERSION;
756
		$responseData = ['subsonic-response' => $content];
757
758
		if ($this->format == 'json') {
759
			$response = new JSONResponse($responseData);
760
		} else if ($this->format == 'jsonp') {
761
			$responseData = \json_encode($responseData);
762
			$response = new DataDisplayResponse("{$this->callback}($responseData);");
763
			$response->addHeader('Content-Type', 'text/javascript; charset=UTF-8');
764
		} else {
765
			$response = new XMLResponse($responseData);
766
		}
767
768
		return $response;
769
	}
770
771
	public function subsonicErrorResponse($errorCode, $errorMessage) {
772
		return $this->subsonicResponse([
773
				'error' => [
774
					'code' => $errorCode,
775
					'message' => $errorMessage
776
				]
777
			], 'failed');
778
	}
779
}
780