Passed
Push — master ( 6f900b...ab8614 )
by Pauli
01:54
created

SubsonicController::playlistToApi()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
eloc 6
nc 1
nop 1
dl 0
loc 7
ccs 0
cts 5
cp 0
crap 2
rs 10
c 0
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\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\Album;
35
use \OCA\Music\Db\Artist;
36
use \OCA\Music\Db\Playlist;
37
use \OCA\Music\Db\SortBy;
38
use \OCA\Music\Db\Track;
39
40
use \OCA\Music\Http\FileResponse;
41
use \OCA\Music\Http\XMLResponse;
42
43
use \OCA\Music\Middleware\SubsonicException;
44
45
use \OCA\Music\Utility\CoverHelper;
46
use \OCA\Music\Utility\UserMusicFolder;
47
use \OCA\Music\Utility\Util;
48
49
class SubsonicController extends Controller {
50
	const API_VERSION = '1.8.0';
51
52
	private $albumBusinessLayer;
53
	private $artistBusinessLayer;
54
	private $playlistBusinessLayer;
55
	private $trackBusinessLayer;
56
	private $library;
57
	private $urlGenerator;
58
	private $rootFolder;
59
	private $userMusicFolder;
60
	private $l10n;
61
	private $coverHelper;
62
	private $logger;
63
	private $userId;
64
	private $format;
65
	private $callback;
66
67
	public function __construct($appname,
68
								IRequest $request,
69
								$l10n,
70
								IURLGenerator $urlGenerator,
71
								AlbumBusinessLayer $albumBusinessLayer,
72
								ArtistBusinessLayer $artistBusinessLayer,
73
								PlaylistBusinessLayer $playlistBusinessLayer,
74
								TrackBusinessLayer $trackBusinessLayer,
75
								Library $library,
76
								IRootFolder $rootFolder,
77
								UserMusicFolder $userMusicFolder,
78
								CoverHelper $coverHelper,
79
								Logger $logger) {
80
		parent::__construct($appname, $request);
81
82
		$this->albumBusinessLayer = $albumBusinessLayer;
83
		$this->artistBusinessLayer = $artistBusinessLayer;
84
		$this->playlistBusinessLayer = $playlistBusinessLayer;
85
		$this->trackBusinessLayer = $trackBusinessLayer;
86
		$this->library = $library;
87
		$this->urlGenerator = $urlGenerator;
88
		$this->l10n = $l10n;
89
		$this->rootFolder = $rootFolder;
90
		$this->userMusicFolder = $userMusicFolder;
91
		$this->coverHelper = $coverHelper;
92
		$this->logger = $logger;
93
	}
94
95
	/**
96
	 * Called by the middleware once the user credentials have been checked
97
	 * @param string $userId
98
	 */
99
	public function setAuthenticatedUser($userId) {
100
		$this->userId = $userId;
101
	}
102
103
	/**
104
	 * @NoAdminRequired
105
	 * @PublicPage
106
	 * @NoCSRFRequired
107
	 */
108
	public function handleRequest($method) {
109
		$this->format = $this->request->getParam('f', 'xml');
110
		$this->callback = $this->request->getParam('callback');
111
112
		if (!\in_array($this->format, ['json', 'xml', 'jsonp'])) {
113
			throw new SubsonicException("Unsupported format {$this->format}", 0);
114
		}
115
116
		if ($this->format === 'jsonp' && empty($this->callback)) {
117
			$this->format = 'json';
118
			throw new SubsonicException("Argument 'callback' is required with jsonp format", 10);
119
		}
120
121
		// Allow calling all methods with or without the postfix ".view"
122
		if (Util::endsWith($method, ".view")) {
123
			$method = \substr($method, 0, -\strlen(".view"));
124
		}
125
126
		// Allow calling any functions annotated to be part of the API
127
		if (\method_exists($this, $method)) {
128
			$annotationReader = new MethodAnnotationReader($this, $method);
129
			if ($annotationReader->hasAnnotation('SubsonicAPI')) {
130
				return $this->$method();
131
			}
132
		}
133
134
		$this->logger->log("Request $method not supported", 'warn');
135
		return $this->subsonicErrorResponse(70, "Requested action $method is not supported");
136
	}
137
138
	/* -------------------------------------------------------------------------
139
	 * REST API methods
140
	 *------------------------------------------------------------------------*/
141
142
	/**
143
	 * @SubsonicAPI
144
	 */
145
	private function ping() {
146
		return $this->subsonicResponse([]);
147
	}
148
149
	/**
150
	 * @SubsonicAPI
151
	 */
152
	private function getLicense() {
153
		return $this->subsonicResponse([
154
			'license' => [
155
				'valid' => 'true',
156
				'email' => '',
157
				'licenseExpires' => 'never'
158
			]
159
		]);
160
	}
161
162
	/**
163
	 * @SubsonicAPI
164
	 */
165
	private function getMusicFolders() {
166
		// Only single root folder is supported
167
		return $this->subsonicResponse([
168
			'musicFolders' => ['musicFolder' => [
169
				['id' => 'artists', 'name' => $this->l10n->t('Artists')],
170
				['id' => 'folders', 'name' => $this->l10n->t('Folders')]
171
			]]
172
		]);
173
	}
174
175
	/**
176
	 * @SubsonicAPI
177
	 */
178
	private function getIndexes() {
179
		$id = $this->request->getParam('musicFolderId');
180
181
		if ($id === 'folders') {
182
			return $this->getIndexesForFolders();
183
		} else {
184
			return $this->getIndexesForArtists();
185
		}
186
	}
187
188
	/**
189
	 * @SubsonicAPI
190
	 */
191
	private function getMusicDirectory() {
192
		$id = $this->getRequiredParam('id');
193
194
		if (Util::startsWith($id, 'folder-')) {
195
			return $this->getMusicDirectoryForFolder($id);
196
		} elseif (Util::startsWith($id, 'artist-')) {
197
			return $this->getMusicDirectoryForArtist($id);
198
		} else {
199
			return $this->getMusicDirectoryForAlbum($id);
200
		}
201
	}
202
203
	/**
204
	 * @SubsonicAPI
205
	 */
206
	private function getAlbumList() {
207
		$albums = $this->albumsForGetAlbumList();
208
		return $this->subsonicResponse(['albumList' =>
209
				['album' => \array_map([$this, 'albumToOldApi'], $albums)]
210
		]);
211
	}
212
213
	/**
214
	 * @SubsonicAPI
215
	 */
216
	private function getAlbumList2() {
217
		/*
218
		 * According to the API specification, the difference between this and getAlbumList
219
		 * should be that this function would organize albums according the metadata while
220
		 * getAlbumList would organize them by folders. However, we organize by metadata
221
		 * also in getAlbumList, because that's more natural for the Music app and many/most
222
		 * clients do not support getAlbumList2.
223
		 */
224
		$albums = $this->albumsForGetAlbumList();
225
		return $this->subsonicResponse(['albumList2' =>
226
				['album' => \array_map([$this, 'albumToNewApi'], $albums)]
227
		]);
228
	}
229
230
	/**
231
	 * @SubsonicAPI
232
	 */
233
	private function getArtists() {
234
		return $this->getIndexesForArtists('artists');
235
	}
236
237
	/**
238
	 * @SubsonicAPI
239
	 */
240
	private function getArtist() {
241
		$id = $this->getRequiredParam('id');
242
		$artistId = self::ripIdPrefix($id); // get rid of 'artist-' prefix
243
244
		$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
245
		$artistName = $artist->getNameString($this->l10n);
246
		$albums = $this->albumBusinessLayer->findAllByAlbumArtist($artistId, $this->userId);
247
248
		$artistNode = $this->artistToApi($artist);
249
		$artistNode['album'] = \array_map(function($album) use ($artistName) {
250
			return $this->albumToNewApi($album, $artistName);
251
		}, $albums);
252
253
		return $this->subsonicResponse(['artist' => $artistNode]);
254
	}
255
256
	/**
257
	 * @SubsonicAPI
258
	 */
259
	private function getAlbum() {
260
		$id = $this->getRequiredParam('id');
261
		$albumId = self::ripIdPrefix($id); // get rid of 'album-' prefix
262
263
		$album = $this->albumBusinessLayer->find($albumId, $this->userId);
264
		$albumName = $album->getNameString($this->l10n);
265
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $this->userId);
266
267
		$albumNode = $this->albumToNewApi($album);
268
		$albumNode['song'] = \array_map(function($track) use ($album, $albumName) {
269
			return $this->trackToApi($track, $album, $albumName);
270
		}, $tracks);
271
272
		return $this->subsonicResponse(['album' => $albumNode]);
273
	}
274
275
	/**
276
	 * @SubsonicAPI
277
	 */
278
	private function getSong() {
279
		$id = $this->getRequiredParam('id');
280
		$trackId = self::ripIdPrefix($id); // get rid of 'track-' prefix
281
		$track = $this->trackBusinessLayer->find($trackId, $this->userId);
282
283
		return $this->subsonicResponse(['song' => $this->trackToApi($track)]);
284
	}
285
286
	/**
287
	 * @SubsonicAPI
288
	 */
289
	private function getRandomSongs() {
290
		$size = $this->request->getParam('size', 10);
291
		$size = \min($size, 500); // the API spec limits the maximum amount to 500
292
		// $genre = $this->request->getParam('genre'); not supported
293
		// $fromYear = $this->request->getParam('fromYear'); not supported
294
		// $toYear = $this->request->getParam('genre'); not supported
295
296
		$allTracks = $this->trackBusinessLayer->findAll($this->userId);
297
		$tracks = self::randomItems($allTracks, $size);
298
299
		return $this->subsonicResponse(['randomSongs' =>
300
				['song' => \array_map([$this, 'trackToApi'], $tracks)]
301
		]);
302
	}
303
304
	/**
305
	 * @SubsonicAPI
306
	 */
307
	private function getCoverArt() {
308
		$id = $this->getRequiredParam('id');
309
		$userFolder = $this->rootFolder->getUserFolder($this->userId);
310
		$coverData = $this->coverHelper->getCover($id, $this->userId, $userFolder);
311
312
		if ($coverData !== null) {
0 ignored issues
show
introduced by
The condition $coverData !== null is always true.
Loading history...
313
			return new FileResponse($coverData);
314
		}
315
316
		return $this->subsonicErrorResponse(70, 'album has no cover');
317
	}
318
319
	/**
320
	 * @SubsonicAPI
321
	 */
322
	private function stream() {
323
		// We don't support transcaoding, so 'stream' and 'download' act identically
324
		return $this->download();
325
	}
326
327
	/**
328
	 * @SubsonicAPI
329
	 */
330
	private function download() {
331
		$id = $this->getRequiredParam('id');
332
		$trackId = self::ripIdPrefix($id); // get rid of 'track-' prefix
333
334
		$track = $this->trackBusinessLayer->find($trackId, $this->userId);
335
		$file = $this->getFilesystemNode($track->getFileId());
336
337
		if ($file instanceof File) {
338
			return new FileResponse($file);
339
		} else {
340
			return $this->subsonicErrorResponse(70, 'file not found');
341
		}
342
	}
343
344
	/**
345
	 * @SubsonicAPI
346
	 */
347
	private function search2() {
348
		$results = $this->doSearch();
349
350
		return $this->subsonicResponse(['searchResult2' => [
351
			'artist' => \array_map([$this, 'artistToApi'], $results['artists']),
352
			'album' => \array_map([$this, 'albumToOldApi'], $results['albums']),
353
			'song' => \array_map([$this, 'trackToApi'], $results['tracks'])
354
		]]);
355
	}
356
357
	/**
358
	 * @SubsonicAPI
359
	 */
360
	private function search3() {
361
		$results = $this->doSearch();
362
363
		return $this->subsonicResponse(['searchResult3' => [
364
			'artist' => \array_map([$this, 'artistToApi'], $results['artists']),
365
			'album' => \array_map([$this, 'albumToNewApi'], $results['albums']),
366
			'song' => \array_map([$this, 'trackToApi'], $results['tracks'])
367
		]]);
368
	}
369
370
	/**
371
	 * @SubsonicAPI
372
	 */
373
	private function getPlaylists() {
374
		$playlists = $this->playlistBusinessLayer->findAll($this->userId);
375
376
		return $this->subsonicResponse(['playlists' =>
377
			['playlist' => \array_map([$this, 'playlistToApi'], $playlists)]
378
		]);
379
	}
380
381
	/**
382
	 * @SubsonicAPI
383
	 */
384
	private function getPlaylist() {
385
		$id = $this->getRequiredParam('id');
386
		$playlist = $this->playlistBusinessLayer->find($id, $this->userId);
387
		$tracks = $this->playlistBusinessLayer->getPlaylistTracks($id, $this->userId);
388
389
		$playlistNode = $this->playlistToApi($playlist);
390
		$playlistNode['entry'] = \array_map([$this, 'trackToApi'], $tracks);
391
392
		return $this->subsonicResponse(['playlist' => $playlistNode]);
393
	}
394
395
	/**
396
	 * @SubsonicAPI
397
	 */
398
	private function createPlaylist() {
399
		$name = $this->getRequiredParam('name');
400
		$songIds = $this->getRepeatedParam('songId');
401
		$songIds = \array_map('self::ripIdPrefix', $songIds);
402
403
		$playlist = $this->playlistBusinessLayer->create($name, $this->userId);
404
		$this->playlistBusinessLayer->addTracks($songIds, $playlist->getId(), $this->userId);
405
406
		return $this->subsonicResponse([]);
407
	}
408
409
	/**
410
	 * @SubsonicAPI
411
	 */
412
	private function updatePlaylist() {
413
		$listId = $this->getRequiredParam('playlistId');
414
		$newName = $this->request->getParam('name');
415
		$songIdsToAdd = $this->getRepeatedParam('songIdToAdd');
416
		$songIdsToAdd = \array_map('self::ripIdPrefix', $songIdsToAdd);
417
		$songIndicesToRemove = $this->getRepeatedParam('songIndexToRemove');
418
419
		if (!empty($newName)) {
420
			$this->playlistBusinessLayer->rename($newName, $listId, $this->userId);
421
		}
422
423
		if (!empty($songIndicesToRemove)) {
424
			$this->playlistBusinessLayer->removeTracks($songIndicesToRemove, $listId, $this->userId);
425
		}
426
427
		if (!empty($songIdsToAdd)) {
428
			$this->playlistBusinessLayer->addTracks($songIdsToAdd, $listId, $this->userId);
429
		}
430
431
		return $this->subsonicResponse([]);
432
	}
433
434
	/**
435
	 * @SubsonicAPI
436
	 */
437
	private function deletePlaylist() {
438
		$id = $this->getRequiredParam('id');
439
		$this->playlistBusinessLayer->delete($id, $this->userId);
440
		return $this->subsonicResponse([]);
441
	}
442
443
	/**
444
	 * @SubsonicAPI
445
	 */
446
	private function getUser() {
447
		$username = $this->getRequiredParam('username');
448
449
		if ($username != $this->userId) {
450
			throw new SubsonicException("{$this->userId} is not authorized to get details for other users.", 50);
451
		}
452
453
		return $this->subsonicResponse([
454
			'user' => [
455
				'username' => $username,
456
				'email' => '',
457
				'scrobblingEnabled' => false,
458
				'adminRole' => false,
459
				'settingsRole' => false,
460
				'downloadRole' => true,
461
				'uploadRole' => false,
462
				'playlistRole' => true,
463
				'coverArtRole' => false,
464
				'commentRole' => false,
465
				'podcastRole' => false,
466
				'streamRole' => true,
467
				'jukeboxRole' => false,
468
				'shareRole' => false,
469
				'videoConversionRole' => false,
470
				'folder' => ['artists', 'folders'],
471
			]
472
		]);
473
	}
474
475
	/**
476
	 * @SubsonicAPI
477
	 */
478
	private function getUsers() {
479
		throw new SubsonicException("{$this->userId} is not authorized to get details for other users.", 50);
480
	}
481
482
	/**
483
	 * @SubsonicAPI
484
	 */
485
	private function getAvatar() {
486
		// TODO: Use 'username' parameter to fetch user-specific avatar from the OC core.
487
		// Remember to check the permission.
488
		// For now, use the Music app logo for all users.
489
		$fileName = \join(DIRECTORY_SEPARATOR, [\dirname(__DIR__), 'img', 'logo', 'music_logo.png']);
490
		$content = \file_get_contents($fileName);
491
		return new FileResponse(['content' => $content, 'mimetype' => 'image/png']);
492
	}
493
494
	/**
495
	 * @SubsonicAPI
496
	 */
497
	private function getStarred() {
498
		// TODO: dummy implementation
499
		return $this->subsonicResponse([
500
			'starred' => [
501
				'artist' => [],
502
				'album' => [],
503
				'song' => []
504
			]
505
		]);
506
	}
507
508
	/**
509
	 * @SubsonicAPI
510
	 */
511
	private function getStarred2() {
512
		// TODO: dummy implementation
513
		return $this->subsonicResponse([
514
			'starred2' => [
515
				'artist' => [],
516
				'album' => [],
517
				'song' => []
518
			]
519
		]);
520
	}
521
522
	/**
523
	 * @SubsonicAPI
524
	 */
525
	private function getVideos() {
526
		// TODO: dummy implementation
527
		return $this->subsonicResponse([
528
			'videos' => [
529
				'video' => []
530
			]
531
		]);
532
	}
533
534
	/* -------------------------------------------------------------------------
535
	 * Helper methods
536
	 *------------------------------------------------------------------------*/
537
538
	private function getRequiredParam($paramName) {
539
		$param = $this->request->getParam($paramName);
540
541
		if ($param === null) {
542
			throw new SubsonicException("Required parameter '$paramName' missing", 10);
543
		}
544
545
		return $param;
546
	}
547
548
	/** 
549
	 * Get values for parameter which may be present multiple times in the query
550
	 * string or POST data.
551
	 * @param string $paramName
552
	 * @return string[]
553
	 */
554
	private function getRepeatedParam($paramName) {
555
		// We can't use the IRequest object nor $_GET and $_POST to get the data
556
		// because all of these are based to the idea of unique parameter names.
557
		// If the same name is repeated, only the last value is saved. Hence, we
558
		// need to parse the raw data manually.
559
560
		// query string is always present (although it could be empty)
561
		$values = $this->parseRepeatedKeyValues($paramName, $_SERVER['QUERY_STRING']);
562
563
		// POST data is available if the method is POST
564
		if ($this->request->getMethod() == 'POST') {
565
			$values = \array_merge($values,
566
					$this->parseRepeatedKeyValues($paramName, file_get_contents('php://input')));
567
		}
568
569
		return $values;
570
	}
571
572
	/**
573
	 * Parse a string like "someKey=value1&someKey=value2&anotherKey=valueA&someKey=value3"
574
	 * and return an array of values for the given key
575
	 * @param string $key
576
	 * @param string $data
577
	 */
578
	private function parseRepeatedKeyValues($key, $data) {
579
		$result = [];
580
581
		$keyValuePairs = \explode('&', $data);
582
583
		foreach ($keyValuePairs as $pair) {
584
			$keyAndValue = \explode('=', $pair);
585
586
			if ($keyAndValue[0] == $key) {
587
				$result[] = $keyAndValue[1];
588
			}
589
		}
590
591
		return $result;
592
	}
593
594
	private function getFilesystemNode($id) {
595
		$nodes = $this->rootFolder->getUserFolder($this->userId)->getById($id);
596
597
		if (\count($nodes) != 1) {
598
			throw new SubsonicException('file not found', 70);
599
		}
600
601
		return $nodes[0];
602
	}
603
604
	private function getIndexesForFolders() {
605
		$rootFolder = $this->userMusicFolder->getFolder($this->userId);
606
	
607
		return $this->subsonicResponse(['indexes' => ['index' => [
608
				['name' => '*',
609
				'artist' => [['id' => 'folder-' . $rootFolder->getId(), 'name' => $rootFolder->getName()]]]
610
				]]]);
611
	}
612
	
613
	private function getMusicDirectoryForFolder($id) {
614
		$folderId = self::ripIdPrefix($id); // get rid of 'folder-' prefix
615
		$folder = $this->getFilesystemNode($folderId);
616
617
		if (!($folder instanceof Folder)) {
618
			throw new SubsonicException("$id is not a valid folder", 70);
619
		}
620
621
		$nodes = $folder->getDirectoryListing();
622
		$subFolders = \array_filter($nodes, function ($n) {
623
			return $n instanceof Folder;
624
		});
625
		$tracks = $this->trackBusinessLayer->findAllByFolder($folderId, $this->userId);
626
627
		$children = \array_merge(
628
			\array_map([$this, 'folderToApi'], $subFolders),
629
			\array_map([$this, 'trackToApi'], $tracks)
630
		);
631
632
		return $this->subsonicResponse([
633
			'directory' => [
634
				'id' => $id,
635
				'parent' => 'folder-' . $folder->getParent()->getId(),
636
				'name' => $folder->getName(),
637
				'child' => $children
638
			]
639
		]);
640
	}
641
642
	private function getIndexesForArtists($rootElementName = 'indexes') {
643
		$artists = $this->artistBusinessLayer->findAllHavingAlbums($this->userId, SortBy::Name);
644
	
645
		$indexes = [];
646
		foreach ($artists as $artist) {
647
			$indexes[$artist->getIndexingChar()][] = $this->artistToApi($artist);
648
		}
649
	
650
		$result = [];
651
		foreach ($indexes as $indexChar => $bucketArtists) {
652
			$result[] = ['name' => $indexChar, 'artist' => $bucketArtists];
653
		}
654
	
655
		return $this->subsonicResponse([$rootElementName => ['index' => $result]]);
656
	}
657
658
	private function getMusicDirectoryForArtist($id) {
659
		$artistId = self::ripIdPrefix($id); // get rid of 'artist-' prefix
660
661
		$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
662
		$artistName = $artist->getNameString($this->l10n);
663
		$albums = $this->albumBusinessLayer->findAllByAlbumArtist($artistId, $this->userId);
664
665
		$children = [];
666
		foreach ($albums as $album) {
667
			$children[] = $this->albumToOldApi($album, $artistName);
668
		}
669
670
		return $this->subsonicResponse([
671
			'directory' => [
672
				'id' => $id,
673
				'parent' => 'artists',
674
				'name' => $artistName,
675
				'child' => $children
676
			]
677
		]);
678
	}
679
680
	private function getMusicDirectoryForAlbum($id) {
681
		$albumId = self::ripIdPrefix($id); // get rid of 'album-' prefix
682
683
		$album = $this->albumBusinessLayer->find($albumId, $this->userId);
684
		$albumName = $album->getNameString($this->l10n);
685
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $this->userId);
686
687
		return $this->subsonicResponse([
688
			'directory' => [
689
				'id' => $id,
690
				'parent' => 'artist-' . $album->getAlbumArtistId(),
691
				'name' => $albumName,
692
				'child' => \array_map([$this, 'trackToApi'], $tracks)
693
			]
694
		]);
695
	}
696
697
	/**
698
	 * @param Folder $folder
699
	 * @return array
700
	 */
701
	private function folderToApi($folder) {
702
		return [
703
			'id' => 'folder-' . $folder->getId(),
704
			'title' => $folder->getName(),
705
			'isDir' => true
706
		];
707
	}
708
709
	/**
710
	 * @param Artist $artist
711
	 * @return array
712
	 */
713
	private function artistToApi($artist) {
714
		return [
715
			'name' => $artist->getNameString($this->l10n),
716
			'id' => 'artist-' . $artist->getId(),
717
			'albumCount' => $this->albumBusinessLayer->countByArtist($artist->getId())
718
		];
719
	}
720
721
	/**
722
	 * The "old API" format is used e.g. in getMusicDirectory and getAlbumList
723
	 * @param Album $album
724
	 * @param string|null $artistName
725
	 * @return array
726
	 */
727
	private function albumToOldApi($album, $artistName = null) {
728
		$artistId = $album->getAlbumArtistId();
729
730
		if (empty($artistName)) {
731
			$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
732
			$artistName = $artist->getNameString($this->l10n);
733
		}
734
735
		$result = [
736
			'id' => 'album-' . $album->getId(),
737
			'parent' => 'artist-' . $artistId,
738
			'title' => $album->getNameString($this->l10n),
739
			'artist' => $artistName,
740
			'isDir' => true
741
		];
742
743
		if (!empty($album->getCoverFileId())) {
744
			$result['coverArt'] = $album->getId();
745
		}
746
747
		return $result;
748
	}
749
750
	/**
751
	 * The "new API" format is used e.g. in getAlbum and getAlbumList2
752
	 * @param Album $album
753
	 * @param string|null $artistName
754
	 * @return array
755
	 */
756
	private function albumToNewApi($album, $artistName = null) {
757
		$artistId = $album->getAlbumArtistId();
758
759
		if (empty($artistName)) {
760
			$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
761
			$artistName = $artist->getNameString($this->l10n);
762
		}
763
764
		$result = [
765
			'id' => 'album-' . $album->getId(),
766
			'artistId' => 'artist-' . $artistId,
767
			'name' => $album->getNameString($this->l10n),
768
			'artist' => $artistName,
769
			'songCount' => $this->trackBusinessLayer->countByAlbum($album->getId()),
770
			//'duration' => 0
771
		];
772
773
		if (!empty($album->getCoverFileId())) {
774
			$result['coverArt'] = $album->getId();
775
		}
776
777
		return $result;
778
	}
779
780
	/**
781
	 * The same API format is used both on "old" and "new" API methods. The "new" API adds some
782
	 * new fields for the songs, but providing some extra fields shouldn't be a problem for the
783
	 * older clients. 
784
	 * @param Track $track
785
	 * @param Album|null $album
786
	 * @param string|null $albumName
787
	 * @return array
788
	 */
789
	private function trackToApi($track, $album = null, $albumName = null) {
790
		$albumId = $track->getAlbumId();
791
		if ($album == null) {
792
			$album = $this->albumBusinessLayer->find($albumId, $this->userId);
793
		}
794
		if (empty($albumName)) {
795
			$albumName = $album->getNameString($this->l10n);
796
		}
797
798
		$trackArtist = $this->artistBusinessLayer->find($track->getArtistId(), $this->userId);
799
		$result = [
800
			'id' => 'track-' . $track->getId(),
801
			'parent' => 'album-' . $albumId,
802
			'title' => $track->getTitle(),
803
			'artist' => $trackArtist->getNameString($this->l10n),
804
			'isDir' => false,
805
			'album' => $albumName,
806
			//'genre' => '',
807
			'year' => $track->getYear(),
808
			'size' => $track->getSize(),
809
			'contentType' => $track->getMimetype(),
810
			'suffix' => $track->getFileExtension(),
811
			'duration' => $track->getLength() ?: 0,
812
			'bitRate' => \round($track->getBitrate()/1000) ?: 0, // convert bps to kbps
813
			//'path' => '',
814
			'isVideo' => false,
815
			'albumId' => 'album-' . $albumId,
816
			'artistId' => 'artist-' . $track->getArtistId(),
817
			'type' => 'music'
818
		];
819
820
		if (!empty($album->getCoverFileId())) {
821
			$result['coverArt'] = $album->getId();
822
		}
823
824
		if ($track->getNumber() !== null) {
0 ignored issues
show
introduced by
The condition $track->getNumber() !== null is always true.
Loading history...
825
			$result['track'] = $track->getNumber();
826
		}
827
828
		return $result;
829
	}
830
831
	/**
832
	 * @param Playlist $playlist
833
	 * @return array
834
	 */
835
	private function playlistToApi($playlist) {
836
		return [
837
			'id' => $playlist->getId(),
838
			'name' => $playlist->getName(),
839
			'owner' => $this->userId,
840
			'public' => false,
841
			'songCount' => $playlist->getTrackCount(),
842
			// comment => '',
843
			// duration => '',
844
			// created => '',
845
			// coverArt => ''
846
		];
847
	}
848
849
	/**
850
	 * Common logic for getAlbumList and getAlbumList2
851
	 * @return Album[]
852
	 */
853
	private function albumsForGetAlbumList() {
854
		$type = $this->getRequiredParam('type');
855
		$size = $this->request->getParam('size', 10);
856
		$size = \min($size, 500); // the API spec limits the maximum amount to 500
857
		$offset = $this->request->getParam('offset', 0);
858
859
		$albums = [];
860
861
		switch ($type) {
862
			case 'random':
863
				$allAlbums = $this->albumBusinessLayer->findAll($this->userId);
864
				$albums = self::randomItems($allAlbums, $size);
865
				// Note: offset is not supported on this type
866
				break;
867
			case 'alphabeticalByName':
868
				$albums = $this->albumBusinessLayer->findAll($this->userId, SortBy::Name, $size, $offset);
869
				break;
870
			case 'alphabeticalByArtist':
871
			case 'newest':
872
			case 'highest':
873
			case 'frequent':
874
			case 'recent':
875
			case 'starred':
876
			case 'byYear':
877
			case 'byGenre':
878
			default:
879
				$this->logger->log("Album list type '$type' is not supported", 'debug');
880
				break;
881
		}
882
883
		return $albums;
884
	}
885
886
	/**
887
	 * Common logic for search2 and search3
888
	 * @return array with keys 'artists', 'albums', and 'tracks'
889
	 */
890
	private function doSearch() {
891
		$query = $this->getRequiredParam('query');
892
		$artistCount = $this->request->getParam('artistCount', 20);
893
		$artistOffset = $this->request->getParam('artistOffset', 0);
894
		$albumCount = $this->request->getParam('albumCount', 20);
895
		$albumOffset = $this->request->getParam('albumOffset', 0);
896
		$songCount = $this->request->getParam('songCount', 20);
897
		$songOffset = $this->request->getParam('songOffset', 0);
898
899
		if (empty($query)) {
900
			throw new SubsonicException("The 'query' argument is mandatory", 10);
901
		}
902
903
		return [
904
			'artists' => $this->artistBusinessLayer->findAllByName($query, $this->userId, true, $artistCount, $artistOffset),
905
			'albums' => $this->albumBusinessLayer->findAllByName($query, $this->userId, true, $albumCount, $albumOffset),
906
			'tracks' => $this->trackBusinessLayer->findAllByName($query, $this->userId, true, $songCount, $songOffset)
907
		];
908
	}
909
910
	/**
911
	 * Given a prefixed ID like 'artist-123' or 'track-45', return just the numeric part.
912
	 * @param string $id
913
	 * @return integer
914
	 */
915
	private static function ripIdPrefix($id) {
916
		return (int)(\explode('-', $id)[1]);
917
	}
918
919
	private static function randomItems($itemArray, $count) {
920
		$count = \min($count, \count($itemArray)); // can't return more than all items
921
		$indices = \array_rand($itemArray, $count);
922
		if ($count == 1) { // return type is not array when randomizing a single index
923
			$indices = [$indices];
924
		}
925
926
		$result = [];
927
		foreach ($indices as $index) {
928
			$result[] = $itemArray[$index];
929
		}
930
931
		return $result;
932
	}
933
934
	private function subsonicResponse($content, $status = 'ok') {
935
		$content['status'] = $status; 
936
		$content['version'] = self::API_VERSION;
937
		$responseData = ['subsonic-response' => $content];
938
939
		if ($this->format == 'json') {
940
			$response = new JSONResponse($responseData);
941
		} else if ($this->format == 'jsonp') {
942
			$responseData = \json_encode($responseData);
943
			$response = new DataDisplayResponse("{$this->callback}($responseData);");
944
			$response->addHeader('Content-Type', 'text/javascript; charset=UTF-8');
945
		} else {
946
			$response = new XMLResponse($responseData);
947
		}
948
949
		return $response;
950
	}
951
952
	public function subsonicErrorResponse($errorCode, $errorMessage) {
953
		return $this->subsonicResponse([
954
				'error' => [
955
					'code' => $errorCode,
956
					'message' => $errorMessage
957
				]
958
			], 'failed');
959
	}
960
}
961