Passed
Push — master ( 6d3e46...96e47d )
by Pauli
02:27
created

SubsonicController::__construct()   A

Complexity

Conditions 2
Paths 1

Size

Total Lines 34
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 2
eloc 15
c 2
b 0
f 0
nc 1
nop 14
dl 0
loc 34
ccs 0
cts 16
cp 0
crap 6
rs 9.7666

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

1
<?php
2
3
/**
4
 * ownCloud - Music app
5
 *
6
 * This file is licensed under the Affero General Public License version 3 or
7
 * later. See the COPYING file.
8
 *
9
 * @author Pauli Järvinen <[email protected]>
10
 * @copyright Pauli Järvinen 2019, 2020
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\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...
21
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...
22
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\Album;
33
use \OCA\Music\Db\Artist;
34
use \OCA\Music\Db\Playlist;
35
use \OCA\Music\Db\SortBy;
36
use \OCA\Music\Db\Track;
37
38
use \OCA\Music\Http\FileResponse;
39
use \OCA\Music\Http\XMLResponse;
40
41
use \OCA\Music\Middleware\SubsonicException;
42
43
use \OCA\Music\Utility\CoverHelper;
44
use \OCA\Music\Utility\DetailsHelper;
45
use \OCA\Music\Utility\Random;
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 $userMusicFolder;
59
	private $l10n;
60
	private $coverHelper;
61
	private $detailsHelper;
62
	private $random;
63
	private $logger;
64
	private $userId;
65
	private $format;
66
	private $callback;
67
	private $timezone;
68
69
	public function __construct($appname,
70
								IRequest $request,
71
								$l10n,
72
								IURLGenerator $urlGenerator,
73
								AlbumBusinessLayer $albumBusinessLayer,
74
								ArtistBusinessLayer $artistBusinessLayer,
75
								PlaylistBusinessLayer $playlistBusinessLayer,
76
								TrackBusinessLayer $trackBusinessLayer,
77
								Library $library,
78
								UserMusicFolder $userMusicFolder,
79
								CoverHelper $coverHelper,
80
								DetailsHelper $detailsHelper,
81
								Random $random,
82
								Logger $logger) {
83
		parent::__construct($appname, $request);
84
85
		$this->albumBusinessLayer = $albumBusinessLayer;
86
		$this->artistBusinessLayer = $artistBusinessLayer;
87
		$this->playlistBusinessLayer = $playlistBusinessLayer;
88
		$this->trackBusinessLayer = $trackBusinessLayer;
89
		$this->library = $library;
90
		$this->urlGenerator = $urlGenerator;
91
		$this->l10n = $l10n;
92
		$this->userMusicFolder = $userMusicFolder;
93
		$this->coverHelper = $coverHelper;
94
		$this->detailsHelper = $detailsHelper;
95
		$this->random = $random;
96
		$this->logger = $logger;
97
98
		// For timestamps in the Subsonic API, we would prefer to use the local timezone,
99
		// but the core has set the default timezone as 'UTC'. Get the timezone from php.ini
100
		// if available, and store it for later use.
101
		$tz = \ini_get('date.timezone') ?: 'UTC';
102
		$this->timezone = new \DateTimeZone($tz);
103
	}
104
105
	/**
106
	 * Called by the middleware to set the reponse format to be used
107
	 * @param string $format Response format: xml/json/jsonp
108
	 * @param string|null $callback Function name to use if the @a $format is 'jsonp'
109
	 */
110
	public function setResponseFormat($format, $callback) {
111
		$this->format = $format;
112
		$this->callback = $callback;
113
	}
114
115
	/**
116
	 * Called by the middleware once the user credentials have been checked
117
	 * @param string $userId
118
	 */
119
	public function setAuthenticatedUser($userId) {
120
		$this->userId = $userId;
121
	}
122
123
	/**
124
	 * @NoAdminRequired
125
	 * @PublicPage
126
	 * @NoCSRFRequired
127
	 */
128
	public function handleRequest($method) {
129
		$this->logger->log("Subsonic request $method", 'debug');
130
131
		// Allow calling all methods with or without the postfix ".view"
132
		if (Util::endsWith($method, ".view")) {
133
			$method = \substr($method, 0, -\strlen(".view"));
134
		}
135
136
		// Allow calling any functions annotated to be part of the API
137
		if (\method_exists($this, $method)) {
138
			$annotationReader = new MethodAnnotationReader($this, $method);
139
			if ($annotationReader->hasAnnotation('SubsonicAPI')) {
140
				return $this->$method();
141
			}
142
		}
143
144
		$this->logger->log("Request $method not supported", 'warn');
145
		return $this->subsonicErrorResponse(70, "Requested action $method is not supported");
146
	}
147
148
	/* -------------------------------------------------------------------------
149
	 * REST API methods
150
	 *------------------------------------------------------------------------*/
151
152
	/**
153
	 * @SubsonicAPI
154
	 */
155
	private function ping() {
156
		return $this->subsonicResponse([]);
157
	}
158
159
	/**
160
	 * @SubsonicAPI
161
	 */
162
	private function getLicense() {
163
		return $this->subsonicResponse([
164
			'license' => [
165
				'valid' => 'true',
166
				'email' => '',
167
				'licenseExpires' => 'never'
168
			]
169
		]);
170
	}
171
172
	/**
173
	 * @SubsonicAPI
174
	 */
175
	private function getMusicFolders() {
176
		// Only single root folder is supported
177
		return $this->subsonicResponse([
178
			'musicFolders' => ['musicFolder' => [
179
				['id' => 'artists', 'name' => $this->l10n->t('Artists')],
180
				['id' => 'folders', 'name' => $this->l10n->t('Folders')]
181
			]]
182
		]);
183
	}
184
185
	/**
186
	 * @SubsonicAPI
187
	 */
188
	private function getIndexes() {
189
		$id = $this->request->getParam('musicFolderId');
190
191
		if ($id === 'folders') {
192
			return $this->getIndexesForFolders();
193
		} else {
194
			return $this->getIndexesForArtists();
195
		}
196
	}
197
198
	/**
199
	 * @SubsonicAPI
200
	 */
201
	private function getMusicDirectory() {
202
		$id = $this->getRequiredParam('id');
203
204
		if (Util::startsWith($id, 'folder-')) {
205
			return $this->getMusicDirectoryForFolder($id);
206
		} elseif (Util::startsWith($id, 'artist-')) {
207
			return $this->getMusicDirectoryForArtist($id);
208
		} else {
209
			return $this->getMusicDirectoryForAlbum($id);
210
		}
211
	}
212
213
	/**
214
	 * @SubsonicAPI
215
	 */
216
	private function getAlbumList() {
217
		$albums = $this->albumsForGetAlbumList();
218
		return $this->subsonicResponse(['albumList' =>
219
				['album' => \array_map([$this, 'albumToOldApi'], $albums)]
220
		]);
221
	}
222
223
	/**
224
	 * @SubsonicAPI
225
	 */
226
	private function getAlbumList2() {
227
		/*
228
		 * According to the API specification, the difference between this and getAlbumList
229
		 * should be that this function would organize albums according the metadata while
230
		 * getAlbumList would organize them by folders. However, we organize by metadata
231
		 * also in getAlbumList, because that's more natural for the Music app and many/most
232
		 * clients do not support getAlbumList2.
233
		 */
234
		$albums = $this->albumsForGetAlbumList();
235
		return $this->subsonicResponse(['albumList2' =>
236
				['album' => \array_map([$this, 'albumToNewApi'], $albums)]
237
		]);
238
	}
239
240
	/**
241
	 * @SubsonicAPI
242
	 */
243
	private function getArtists() {
244
		return $this->getIndexesForArtists('artists');
245
	}
246
247
	/**
248
	 * @SubsonicAPI
249
	 */
250
	private function getArtist() {
251
		$id = $this->getRequiredParam('id');
252
		$artistId = self::ripIdPrefix($id); // get rid of 'artist-' prefix
253
254
		$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
255
		$artistName = $artist->getNameString($this->l10n);
256
		$albums = $this->albumBusinessLayer->findAllByAlbumArtist($artistId, $this->userId);
257
258
		$artistNode = $this->artistToApi($artist);
259
		$artistNode['album'] = \array_map(function($album) use ($artistName) {
260
			return $this->albumToNewApi($album, $artistName);
261
		}, $albums);
262
263
		return $this->subsonicResponse(['artist' => $artistNode]);
264
	}
265
266
	/**
267
	 * @SubsonicAPI
268
	 */
269
	private function getAlbum() {
270
		$id = $this->getRequiredParam('id');
271
		$albumId = self::ripIdPrefix($id); // get rid of 'album-' prefix
272
273
		$album = $this->albumBusinessLayer->find($albumId, $this->userId);
274
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $this->userId);
275
276
		$albumNode = $this->albumToNewApi($album);
277
		$albumNode['song'] = \array_map(function($track) use ($album) {
278
			$track->setAlbum($album);
279
			return $this->trackToApi($track);
280
		}, $tracks);
281
		return $this->subsonicResponse(['album' => $albumNode]);
282
	}
283
284
	/**
285
	 * @SubsonicAPI
286
	 */
287
	private function getSong() {
288
		$id = $this->getRequiredParam('id');
289
		$trackId = self::ripIdPrefix($id); // get rid of 'track-' prefix
290
		$track = $this->trackBusinessLayer->find($trackId, $this->userId);
291
292
		return $this->subsonicResponse(['song' => $this->trackToApi($track)]);
293
	}
294
295
	/**
296
	 * @SubsonicAPI
297
	 */
298
	private function getRandomSongs() {
299
		$size = $this->request->getParam('size', 10);
300
		$size = \min($size, 500); // the API spec limits the maximum amount to 500
301
		// $genre = $this->request->getParam('genre'); not supported
302
		// $fromYear = $this->request->getParam('fromYear'); not supported
303
		// $toYear = $this->request->getParam('genre'); not supported
304
305
		$allTracks = $this->trackBusinessLayer->findAll($this->userId);
306
		$tracks = Random::pickItems($allTracks, $size);
307
308
		return $this->subsonicResponse(['randomSongs' =>
309
				['song' => \array_map([$this, 'trackToApi'], $tracks)]
310
		]);
311
	}
312
313
	/**
314
	 * @SubsonicAPI
315
	 */
316
	private function getCoverArt() {
317
		$id = $this->getRequiredParam('id');
318
		$size = $this->request->getParam('size');
319
320
		$rootFolder = $this->userMusicFolder->getFolder($this->userId);
321
		$coverData = $this->coverHelper->getCover($id, $this->userId, $rootFolder, $size);
322
323
		if ($coverData !== null) {
324
			return new FileResponse($coverData);
325
		}
326
327
		return $this->subsonicErrorResponse(70, 'album has no cover');
328
	}
329
330
	/**
331
	 * @SubsonicAPI
332
	 */
333
	private function getLyrics() {
334
		$artistPar = $this->request->getParam('artist');
335
		$titlePar = $this->request->getParam('title');
336
337
		$matches = $this->trackBusinessLayer->findAllByNameAndArtistName($titlePar, $artistPar, $this->userId);
338
		$matchingCount = \count($matches);
339
340
		if ($matchingCount === 0) {
341
			$this->logger->log("No matching track for title '$titlePar' and artist '$artistPar'", 'debug');
342
			return $this->subsonicResponse(['lyrics' => new \stdClass]);
343
		}
344
		else {
345
			if ($matchingCount > 1) {
346
				$this->logger->log("Found $matchingCount tracks matching title ".
347
									"'$titlePar' and artist '$artistPar'; using the first", 'debug');
348
			}
349
			$track = $matches[0];
350
351
			$artist = $this->artistBusinessLayer->find($track->getArtistId(), $this->userId);
352
			$rootFolder = $this->userMusicFolder->getFolder($this->userId);
353
			$lyrics = $this->detailsHelper->getLyrics($track->getFileId(), $rootFolder);
354
355
			return $this->subsonicResponse(['lyrics' => [
356
					'artist' => $artist->getNameString($this->l10n),
357
					'title' => $track->getTitle(),
358
					'value' => $lyrics
359
			]]);
360
		}
361
	}
362
363
	/**
364
	 * @SubsonicAPI
365
	 */
366
	private function stream() {
367
		// We don't support transcaoding, so 'stream' and 'download' act identically
368
		return $this->download();
369
	}
370
371
	/**
372
	 * @SubsonicAPI
373
	 */
374
	private function download() {
375
		$id = $this->getRequiredParam('id');
376
		$trackId = self::ripIdPrefix($id); // get rid of 'track-' prefix
377
378
		$track = $this->trackBusinessLayer->find($trackId, $this->userId);
379
		$file = $this->getFilesystemNode($track->getFileId());
380
381
		if ($file instanceof File) {
382
			return new FileResponse($file);
383
		} else {
384
			return $this->subsonicErrorResponse(70, 'file not found');
385
		}
386
	}
387
388
	/**
389
	 * @SubsonicAPI
390
	 */
391
	private function search2() {
392
		$results = $this->doSearch();
393
		return $this->searchResponse('searchResult2', $results, /*$useNewApi=*/false);
394
	}
395
396
	/**
397
	 * @SubsonicAPI
398
	 */
399
	private function search3() {
400
		$results = $this->doSearch();
401
		return $this->searchResponse('searchResult3', $results, /*$useNewApi=*/true);
402
	}
403
404
	/**
405
	 * @SubsonicAPI
406
	 */
407
	private function getPlaylists() {
408
		$playlists = $this->playlistBusinessLayer->findAll($this->userId);
409
410
		return $this->subsonicResponse(['playlists' =>
411
			['playlist' => \array_map([$this, 'playlistToApi'], $playlists)]
412
		]);
413
	}
414
415
	/**
416
	 * @SubsonicAPI
417
	 */
418
	private function getPlaylist() {
419
		$id = $this->getRequiredParam('id');
420
		$playlist = $this->playlistBusinessLayer->find($id, $this->userId);
421
		$tracks = $this->playlistBusinessLayer->getPlaylistTracks($id, $this->userId);
422
423
		$playlistNode = $this->playlistToApi($playlist);
424
		$playlistNode['entry'] = \array_map([$this, 'trackToApi'], $tracks);
425
426
		return $this->subsonicResponse(['playlist' => $playlistNode]);
427
	}
428
429
	/**
430
	 * @SubsonicAPI
431
	 */
432
	private function createPlaylist() {
433
		$name = $this->getRequiredParam('name');
434
		$songIds = $this->getRepeatedParam('songId');
435
		$songIds = \array_map('self::ripIdPrefix', $songIds);
436
437
		$playlist = $this->playlistBusinessLayer->create($name, $this->userId);
438
		$this->playlistBusinessLayer->addTracks($songIds, $playlist->getId(), $this->userId);
439
440
		return $this->subsonicResponse([]);
441
	}
442
443
	/**
444
	 * @SubsonicAPI
445
	 */
446
	private function updatePlaylist() {
447
		$listId = $this->getRequiredParam('playlistId');
448
		$newName = $this->request->getParam('name');
449
		$songIdsToAdd = $this->getRepeatedParam('songIdToAdd');
450
		$songIdsToAdd = \array_map('self::ripIdPrefix', $songIdsToAdd);
451
		$songIndicesToRemove = $this->getRepeatedParam('songIndexToRemove');
452
453
		if (!empty($newName)) {
454
			$this->playlistBusinessLayer->rename($newName, $listId, $this->userId);
455
		}
456
457
		if (!empty($songIndicesToRemove)) {
458
			$this->playlistBusinessLayer->removeTracks($songIndicesToRemove, $listId, $this->userId);
459
		}
460
461
		if (!empty($songIdsToAdd)) {
462
			$this->playlistBusinessLayer->addTracks($songIdsToAdd, $listId, $this->userId);
463
		}
464
465
		return $this->subsonicResponse([]);
466
	}
467
468
	/**
469
	 * @SubsonicAPI
470
	 */
471
	private function deletePlaylist() {
472
		$id = $this->getRequiredParam('id');
473
		$this->playlistBusinessLayer->delete($id, $this->userId);
474
		return $this->subsonicResponse([]);
475
	}
476
477
	/**
478
	 * @SubsonicAPI
479
	 */
480
	private function getUser() {
481
		$username = $this->getRequiredParam('username');
482
483
		if ($username != $this->userId) {
484
			throw new SubsonicException("{$this->userId} is not authorized to get details for other users.", 50);
485
		}
486
487
		return $this->subsonicResponse([
488
			'user' => [
489
				'username' => $username,
490
				'email' => '',
491
				'scrobblingEnabled' => false,
492
				'adminRole' => false,
493
				'settingsRole' => false,
494
				'downloadRole' => true,
495
				'uploadRole' => false,
496
				'playlistRole' => true,
497
				'coverArtRole' => false,
498
				'commentRole' => false,
499
				'podcastRole' => false,
500
				'streamRole' => true,
501
				'jukeboxRole' => false,
502
				'shareRole' => false,
503
				'videoConversionRole' => false,
504
				'folder' => ['artists', 'folders'],
505
			]
506
		]);
507
	}
508
509
	/**
510
	 * @SubsonicAPI
511
	 */
512
	private function getUsers() {
513
		throw new SubsonicException("{$this->userId} is not authorized to get details for other users.", 50);
514
	}
515
516
	/**
517
	 * @SubsonicAPI
518
	 */
519
	private function getAvatar() {
520
		// TODO: Use 'username' parameter to fetch user-specific avatar from the OC core.
521
		// Remember to check the permission.
522
		// For now, use the Music app logo for all users.
523
		$fileName = \join(DIRECTORY_SEPARATOR, [\dirname(__DIR__), 'img', 'logo', 'music_logo.png']);
524
		$content = \file_get_contents($fileName);
525
		return new FileResponse(['content' => $content, 'mimetype' => 'image/png']);
526
	}
527
528
	/**
529
	 * @SubsonicAPI
530
	 */
531
	private function star() {
532
		$targetIds = $this->getStarringParameters();
533
534
		$this->trackBusinessLayer->setStarred($targetIds['tracks'], $this->userId);
0 ignored issues
show
Bug introduced by
It seems like $targetIds['tracks'] can also be of type string[]; however, parameter $ids of OCA\Music\AppFramework\B...nessLayer::setStarred() does only seem to accept integer[], maybe add an additional type check? ( Ignorable by Annotation )

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

534
		$this->trackBusinessLayer->setStarred(/** @scrutinizer ignore-type */ $targetIds['tracks'], $this->userId);
Loading history...
535
		$this->albumBusinessLayer->setStarred($targetIds['albums'], $this->userId);
536
		$this->artistBusinessLayer->setStarred($targetIds['artists'], $this->userId);
537
538
		return $this->subsonicResponse([]);
539
	}
540
541
	/**
542
	 * @SubsonicAPI
543
	 */
544
	private function unstar() {
545
		$targetIds = $this->getStarringParameters();
546
547
		$this->trackBusinessLayer->unsetStarred($targetIds['tracks'], $this->userId);
0 ignored issues
show
Bug introduced by
It seems like $targetIds['tracks'] can also be of type string[]; however, parameter $ids of OCA\Music\AppFramework\B...ssLayer::unsetStarred() does only seem to accept integer[], maybe add an additional type check? ( Ignorable by Annotation )

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

547
		$this->trackBusinessLayer->unsetStarred(/** @scrutinizer ignore-type */ $targetIds['tracks'], $this->userId);
Loading history...
548
		$this->albumBusinessLayer->unsetStarred($targetIds['albums'], $this->userId);
549
		$this->artistBusinessLayer->unsetStarred($targetIds['artists'], $this->userId);
550
551
		return $this->subsonicResponse([]);
552
	}
553
554
	/**
555
	 * @SubsonicAPI
556
	 */
557
	private function getStarred() {
558
		$starred = $this->doGetStarred();
559
		return $this->searchResponse('starred', $starred, /*$useNewApi=*/false);
560
	}
561
562
	/**
563
	 * @SubsonicAPI
564
	 */
565
	private function getStarred2() {
566
		$starred = $this->doGetStarred();
567
		return $this->searchResponse('starred2', $starred, /*$useNewApi=*/true);
568
	}
569
570
	/**
571
	 * @SubsonicAPI
572
	 */
573
	private function getVideos() {
574
		// TODO: dummy implementation
575
		return $this->subsonicResponse([
576
			'videos' => [
577
				'video' => []
578
			]
579
		]);
580
	}
581
582
	/* -------------------------------------------------------------------------
583
	 * Helper methods
584
	 *------------------------------------------------------------------------*/
585
586
	private function getRequiredParam($paramName) {
587
		$param = $this->request->getParam($paramName);
588
589
		if ($param === null) {
590
			throw new SubsonicException("Required parameter '$paramName' missing", 10);
591
		}
592
593
		return $param;
594
	}
595
596
	/**
597
	 * Get parameters used in the `star` and `unstar` API methods
598
	 */
599
	private function getStarringParameters() {
600
		// album IDs from newer clients
601
		$albumIds = $this->getRepeatedParam('albumId');
602
		$albumIds = \array_map('self::ripIdPrefix', $albumIds);
603
604
		// artist IDs from newer clients
605
		$artistIds = $this->getRepeatedParam('artistId');
606
		$artistIds = \array_map('self::ripIdPrefix', $artistIds);
607
608
		// song IDs from newer clients and song/folder/album/artist IDs from older clients
609
		$ids = $this->getRepeatedParam('id');
610
611
		$trackIds = [];
612
613
		foreach ($ids as $prefixedId) {
614
			$parts = \explode('-', $prefixedId);
615
			$type = $parts[0];
616
			$id = $parts[1];
617
618
			if ($type == 'track') {
619
				$trackIds[] = $id;
620
			} elseif ($type == 'album') {
621
				$albumIds[] = $id;
622
			} elseif ($type == 'artist') {
623
				$artistIds[] = $id;
624
			} elseif ($type == 'folder') {
625
				throw new SubsonicException('Starring folders is not supported', 0);
626
			} else {
627
				throw new SubsonicException("Unexpected ID format: $prefixedId", 0);
628
			}
629
		}
630
631
		return [
632
			'tracks' => $trackIds,
633
			'albums' => $albumIds,
634
			'artists' => $artistIds
635
		];
636
	}
637
638
	/** 
639
	 * Get values for parameter which may be present multiple times in the query
640
	 * string or POST data.
641
	 * @param string $paramName
642
	 * @return string[]
643
	 */
644
	private function getRepeatedParam($paramName) {
645
		// We can't use the IRequest object nor $_GET and $_POST to get the data
646
		// because all of these are based to the idea of unique parameter names.
647
		// If the same name is repeated, only the last value is saved. Hence, we
648
		// need to parse the raw data manually.
649
650
		// query string is always present (although it could be empty)
651
		$values = $this->parseRepeatedKeyValues($paramName, $_SERVER['QUERY_STRING']);
652
653
		// POST data is available if the method is POST
654
		if ($this->request->getMethod() == 'POST') {
655
			$values = \array_merge($values,
656
					$this->parseRepeatedKeyValues($paramName, file_get_contents('php://input')));
657
		}
658
659
		return $values;
660
	}
661
662
	/**
663
	 * Parse a string like "someKey=value1&someKey=value2&anotherKey=valueA&someKey=value3"
664
	 * and return an array of values for the given key
665
	 * @param string $key
666
	 * @param string $data
667
	 */
668
	private function parseRepeatedKeyValues($key, $data) {
669
		$result = [];
670
671
		$keyValuePairs = \explode('&', $data);
672
673
		foreach ($keyValuePairs as $pair) {
674
			$keyAndValue = \explode('=', $pair);
675
676
			if ($keyAndValue[0] == $key) {
677
				$result[] = $keyAndValue[1];
678
			}
679
		}
680
681
		return $result;
682
	}
683
684
	private function getFilesystemNode($id) {
685
		$rootFolder = $this->userMusicFolder->getFolder($this->userId);
686
		$nodes = $rootFolder->getById($id);
687
688
		if (\count($nodes) != 1) {
689
			throw new SubsonicException('file not found', 70);
690
		}
691
692
		return $nodes[0];
693
	}
694
695
	private function getIndexesForFolders() {
696
		$rootFolder = $this->userMusicFolder->getFolder($this->userId);
697
	
698
		return $this->subsonicResponse(['indexes' => ['index' => [
699
				['name' => '*',
700
				'artist' => [['id' => 'folder-' . $rootFolder->getId(), 'name' => $rootFolder->getName()]]]
701
				]]]);
702
	}
703
	
704
	private function getMusicDirectoryForFolder($id) {
705
		$folderId = self::ripIdPrefix($id); // get rid of 'folder-' prefix
706
		$folder = $this->getFilesystemNode($folderId);
707
708
		if (!($folder instanceof Folder)) {
709
			throw new SubsonicException("$id is not a valid folder", 70);
710
		}
711
712
		$nodes = $folder->getDirectoryListing();
713
		$subFolders = \array_filter($nodes, function ($n) {
714
			return $n instanceof Folder;
715
		});
716
		$tracks = $this->trackBusinessLayer->findAllByFolder($folderId, $this->userId);
717
718
		// A folder may contain thousands of audio files, and getting artist and album data
719
		// for each of those individually would take a lot of time and great many DB queries.
720
		// To prevent having to do this in `trackToApi`, we fetch all the albums and artists
721
		// in one go.
722
		$this->injectAlbumsAndArtistsToTracks($tracks);
723
724
		$children = \array_merge(
725
			\array_map([$this, 'folderToApi'], $subFolders),
726
			\array_map([$this, 'trackToApi'], $tracks)
727
		);
728
729
		$content = [
730
			'directory' => [
731
				'id' => $id,
732
				'name' => $folder->getName(),
733
				'child' => $children
734
			]
735
		];
736
737
		// Parent folder ID is included if and only if the parent folder is not the top level
738
		$rootFolderId = $this->userMusicFolder->getFolder($this->userId)->getId();
739
		$parentFolderId = $folder->getParent()->getId();
740
		if ($rootFolderId != $parentFolderId) {
741
			$content['parent'] = 'folder-' . $parentFolderId;
742
		}
743
744
		return $this->subsonicResponse($content);
745
	}
746
747
	private function injectAlbumsAndArtistsToTracks(&$tracks) {
748
		$albumIds = [];
749
		$artistIds = [];
750
751
		// get unique album and artist IDs
752
		foreach ($tracks as $track) {
753
			$albumIds[$track->getAlbumId()] = 1;
754
			$artistIds[$track->getArtistId()] = 1;
755
		}
756
		$albumIds = \array_keys($albumIds);
757
		$artistIds = \array_keys($artistIds);
758
759
		// get the corresponding entities from the business layer
760
		$albums = $this->albumBusinessLayer->findById($albumIds, $this->userId);
761
		$artists = $this->artistBusinessLayer->findById($artistIds, $this->userId);
762
763
		// create hash tables "id => entity" for the albums and artists for fast access
764
		$albumMap = self::createIdLookupTable($albums);
765
		$artistMap = self::createIdLookupTable($artists);
766
767
		// finally, set the references on the tracks
768
		foreach ($tracks as &$track) {
769
			$track->setAlbum($albumMap[$track->getAlbumId()]);
770
			$track->setArtist($artistMap[$track->getArtistId()]);
771
		}
772
	}
773
774
	private static function createIdLookupTable($array) {
775
		$lut = [];
776
		foreach ($array as $item) {
777
			$lut[$item->getId()] = $item;
778
		}
779
		return $lut;
780
	}
781
782
	private function getIndexesForArtists($rootElementName = 'indexes') {
783
		$artists = $this->artistBusinessLayer->findAllHavingAlbums($this->userId, SortBy::Name);
784
	
785
		$indexes = [];
786
		foreach ($artists as $artist) {
787
			$indexes[$artist->getIndexingChar()][] = $this->artistToApi($artist);
788
		}
789
	
790
		$result = [];
791
		foreach ($indexes as $indexChar => $bucketArtists) {
792
			$result[] = ['name' => $indexChar, 'artist' => $bucketArtists];
793
		}
794
	
795
		return $this->subsonicResponse([$rootElementName => ['index' => $result]]);
796
	}
797
798
	private function getMusicDirectoryForArtist($id) {
799
		$artistId = self::ripIdPrefix($id); // get rid of 'artist-' prefix
800
801
		$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
802
		$artistName = $artist->getNameString($this->l10n);
803
		$albums = $this->albumBusinessLayer->findAllByAlbumArtist($artistId, $this->userId);
804
805
		$children = [];
806
		foreach ($albums as $album) {
807
			$children[] = $this->albumToOldApi($album, $artistName);
808
		}
809
810
		return $this->subsonicResponse([
811
			'directory' => [
812
				'id' => $id,
813
				'name' => $artistName,
814
				'child' => $children
815
			]
816
		]);
817
	}
818
819
	private function getMusicDirectoryForAlbum($id) {
820
		$albumId = self::ripIdPrefix($id); // get rid of 'album-' prefix
821
822
		$album = $this->albumBusinessLayer->find($albumId, $this->userId);
823
		$albumName = $album->getNameString($this->l10n);
824
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $this->userId);
825
826
		return $this->subsonicResponse([
827
			'directory' => [
828
				'id' => $id,
829
				'parent' => 'artist-' . $album->getAlbumArtistId(),
830
				'name' => $albumName,
831
				'child' => \array_map(function($track) use ($album) {
832
					$track->setAlbum($album);
833
					return $this->trackToApi($track);
834
				}, $tracks)
835
			]
836
		]);
837
	}
838
839
	/**
840
	 * @param Folder $folder
841
	 * @return array
842
	 */
843
	private function folderToApi($folder) {
844
		return [
845
			'id' => 'folder-' . $folder->getId(),
846
			'title' => $folder->getName(),
847
			'isDir' => true
848
		];
849
	}
850
851
	/**
852
	 * @param Artist $artist
853
	 * @return array
854
	 */
855
	private function artistToApi($artist) {
856
		$result = [
857
			'name' => $artist->getNameString($this->l10n),
858
			'id' => 'artist-' . $artist->getId(),
859
			'albumCount' => $this->albumBusinessLayer->countByArtist($artist->getId())
860
		];
861
862
		if ($artist->getStarred() != null) {
863
			$result['starred'] = $this->formatDateTime($artist->getStarred());
864
		}
865
866
		return $result;
867
	}
868
869
	/**
870
	 * The "old API" format is used e.g. in getMusicDirectory and getAlbumList
871
	 * @param Album $album
872
	 * @param string|null $artistName
873
	 * @return array
874
	 */
875
	private function albumToOldApi($album, $artistName = null) {
876
		$artistId = $album->getAlbumArtistId();
877
878
		if (empty($artistName)) {
879
			$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
880
			$artistName = $artist->getNameString($this->l10n);
881
		}
882
883
		$result = [
884
			'id' => 'album-' . $album->getId(),
885
			'parent' => 'artist-' . $artistId,
886
			'title' => $album->getNameString($this->l10n),
887
			'artist' => $artistName,
888
			'isDir' => true
889
		];
890
891
		if (!empty($album->getCoverFileId())) {
892
			$result['coverArt'] = $album->getId();
893
		}
894
895
		if ($album->getStarred() != null) {
896
			$result['starred'] = $this->formatDateTime($album->getStarred());
897
		}
898
899
		return $result;
900
	}
901
902
	/**
903
	 * The "new API" format is used e.g. in getAlbum and getAlbumList2
904
	 * @param Album $album
905
	 * @param string|null $artistName
906
	 * @return array
907
	 */
908
	private function albumToNewApi($album, $artistName = null) {
909
		$artistId = $album->getAlbumArtistId();
910
911
		if (empty($artistName)) {
912
			$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
913
			$artistName = $artist->getNameString($this->l10n);
914
		}
915
916
		$result = [
917
			'id' => 'album-' . $album->getId(),
918
			'artistId' => 'artist-' . $artistId,
919
			'name' => $album->getNameString($this->l10n),
920
			'artist' => $artistName,
921
			'songCount' => $this->trackBusinessLayer->countByAlbum($album->getId()),
922
			//'duration' => 0
923
		];
924
925
		if (!empty($album->getCoverFileId())) {
926
			$result['coverArt'] = $album->getId();
927
		}
928
929
		if ($album->getStarred() != null) {
930
			$result['starred'] = $this->formatDateTime($album->getStarred());
931
		}
932
933
		return $result;
934
	}
935
936
	/**
937
	 * The same API format is used both on "old" and "new" API methods. The "new" API adds some
938
	 * new fields for the songs, but providing some extra fields shouldn't be a problem for the
939
	 * older clients. 
940
	 * @param Track $track If the track entity has no album and/or artist references set, then
941
	 *                     those are automatically fetched from the respective BusinessLayer modules.
942
	 * @return array
943
	 */
944
	private function trackToApi($track) {
945
		$albumId = $track->getAlbumId();
946
947
		$album = $track->getAlbum();
948
		if (empty($album)) {
949
			$album = $this->albumBusinessLayer->find($albumId, $this->userId);
950
			$track->setAlbum($album);
951
		}
952
953
		$artist = $track->getArtist();
954
		if (empty($artist)) {
955
			$artist = $this->artistBusinessLayer->find($track->getArtistId(), $this->userId);
956
			$track->setArtist($artist);
957
		}
958
959
		$result = [
960
			'id' => 'track-' . $track->getId(),
961
			'parent' => 'album-' . $albumId,
962
			//'discNumber' => $track->getDisk(), // not supported on any of the tested clients => adjust track number instead
963
			'title' => $track->getTitle(),
964
			'artist' => $artist->getNameString($this->l10n),
965
			'isDir' => false,
966
			'album' => $album->getNameString($this->l10n),
967
			//'genre' => '',
968
			'year' => $track->getYear(),
969
			'size' => $track->getSize(),
970
			'contentType' => $track->getMimetype(),
971
			'suffix' => $track->getFileExtension(),
972
			'duration' => $track->getLength() ?: 0,
973
			'bitRate' => \round($track->getBitrate()/1000) ?: 0, // convert bps to kbps
974
			//'path' => '',
975
			'isVideo' => false,
976
			'albumId' => 'album-' . $albumId,
977
			'artistId' => 'artist-' . $track->getArtistId(),
978
			'type' => 'music'
979
		];
980
981
		if (!empty($album->getCoverFileId())) {
982
			$result['coverArt'] = $album->getId();
983
		}
984
985
		$trackNumber = $track->getDiskAdjustedTrackNumber();
986
		if ($trackNumber !== null) {
0 ignored issues
show
introduced by
The condition $trackNumber !== null is always true.
Loading history...
987
			$result['track'] = $trackNumber;
988
		}
989
990
		if ($track->getStarred() != null) {
991
			$result['starred'] = $this->formatDateTime($track->getStarred());
992
		}
993
994
		return $result;
995
	}
996
997
	/**
998
	 * @param Playlist $playlist
999
	 * @return array
1000
	 */
1001
	private function playlistToApi($playlist) {
1002
		return [
1003
			'id' => $playlist->getId(),
1004
			'name' => $playlist->getName(),
1005
			'owner' => $this->userId,
1006
			'public' => false,
1007
			'songCount' => $playlist->getTrackCount(),
1008
			// comment => '',
1009
			// duration => '',
1010
			// created => '',
1011
			// coverArt => ''
1012
		];
1013
	}
1014
1015
	/**
1016
	 * Common logic for getAlbumList and getAlbumList2
1017
	 * @return Album[]
1018
	 */
1019
	private function albumsForGetAlbumList() {
1020
		$type = $this->getRequiredParam('type');
1021
		$size = $this->request->getParam('size', 10);
1022
		$size = \min($size, 500); // the API spec limits the maximum amount to 500
1023
		$offset = $this->request->getParam('offset', 0);
1024
1025
		$albums = [];
1026
1027
		switch ($type) {
1028
			case 'random':
1029
				$allAlbums = $this->albumBusinessLayer->findAll($this->userId);
1030
				$indices = $this->random->getIndices(\count($allAlbums), $offset, $size, $this->userId, 'subsonic_albums');
1031
				$albums = Util::arrayMultiGet($allAlbums, $indices);
1032
				break;
1033
			case 'starred':
1034
				$albums = $this->albumBusinessLayer->findAllStarred($this->userId, $size, $offset);
1035
				break;
1036
			case 'alphabeticalByName':
1037
				$albums = $this->albumBusinessLayer->findAll($this->userId, SortBy::Name, $size, $offset);
1038
				break;
1039
			case 'alphabeticalByArtist':
1040
			case 'newest':
1041
			case 'highest':
1042
			case 'frequent':
1043
			case 'recent':
1044
			case 'byYear':
1045
			case 'byGenre':
1046
			default:
1047
				$this->logger->log("Album list type '$type' is not supported", 'debug');
1048
				break;
1049
		}
1050
1051
		return $albums;
1052
	}
1053
1054
	/**
1055
	 * Common logic for search2 and search3
1056
	 * @return array with keys 'artists', 'albums', and 'tracks'
1057
	 */
1058
	private function doSearch() {
1059
		$query = $this->getRequiredParam('query');
1060
		$artistCount = $this->request->getParam('artistCount', 20);
1061
		$artistOffset = $this->request->getParam('artistOffset', 0);
1062
		$albumCount = $this->request->getParam('albumCount', 20);
1063
		$albumOffset = $this->request->getParam('albumOffset', 0);
1064
		$songCount = $this->request->getParam('songCount', 20);
1065
		$songOffset = $this->request->getParam('songOffset', 0);
1066
1067
		if (empty($query)) {
1068
			throw new SubsonicException("The 'query' argument is mandatory", 10);
1069
		}
1070
1071
		return [
1072
			'artists' => $this->artistBusinessLayer->findAllByName($query, $this->userId, true, $artistCount, $artistOffset),
1073
			'albums' => $this->albumBusinessLayer->findAllByName($query, $this->userId, true, $albumCount, $albumOffset),
1074
			'tracks' => $this->trackBusinessLayer->findAllByName($query, $this->userId, true, $songCount, $songOffset)
1075
		];
1076
	}
1077
1078
	/**
1079
	 * Common logic for getStarred and getStarred2
1080
	 * @return array
1081
	 */
1082
	private function doGetStarred() {
1083
		return [
1084
			'artists' => $this->artistBusinessLayer->findAllStarred($this->userId),
1085
			'albums' => $this->albumBusinessLayer->findAllStarred($this->userId),
1086
			'tracks' => $this->trackBusinessLayer->findAllStarred($this->userId)
1087
		];
1088
	}
1089
1090
	/**
1091
	 * Common response building logic for search2, search3, getStarred, and getStarred2
1092
	 * @param string $title Name of the main node in the response message
1093
	 * @param array $results Search results with keys 'artists', 'albums', and 'tracks'
1094
	 * @param boolean $useNewApi Set to true for search3 and getStarred3. There is a difference
1095
	 *                           in the formatting of the album nodes.
1096
	 * @return \OCP\AppFramework\Http\Response
0 ignored issues
show
Bug introduced by
The type OCP\AppFramework\Http\Response 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...
1097
	 */
1098
	private function searchResponse($title, $results, $useNewApi) {
1099
		$albumMapFunc = $useNewApi ? 'albumToNewApi' : 'albumToOldApi';
1100
1101
		return $this->subsonicResponse([$title => [
1102
			'artist' => \array_map([$this, 'artistToApi'], $results['artists']),
1103
			'album' => \array_map([$this, $albumMapFunc], $results['albums']),
1104
			'song' => \array_map([$this, 'trackToApi'], $results['tracks'])
1105
		]]);
1106
	}
1107
1108
	/**
1109
	 * Given a prefixed ID like 'artist-123' or 'track-45', return just the numeric part.
1110
	 * @param string $id
1111
	 * @return integer
1112
	 */
1113
	private static function ripIdPrefix($id) {
1114
		return (int)(\explode('-', $id)[1]);
1115
	}
1116
1117
	private function formatDateTime($dateString) {
1118
		$dateTime = new \DateTime($dateString);
1119
		$dateTime->setTimezone($this->timezone);
1120
		return $dateTime->format('Y-m-d\TH:i:s');
1121
	}
1122
1123
	private function subsonicResponse($content, $status = 'ok') {
1124
		$content['status'] = $status; 
1125
		$content['version'] = self::API_VERSION;
1126
		$responseData = ['subsonic-response' => $content];
1127
1128
		if ($this->format == 'json') {
1129
			$response = new JSONResponse($responseData);
1130
		} else if ($this->format == 'jsonp') {
1131
			$responseData = \json_encode($responseData);
1132
			$response = new DataDisplayResponse("{$this->callback}($responseData);");
1133
			$response->addHeader('Content-Type', 'text/javascript; charset=UTF-8');
1134
		} else {
1135
			$response = new XMLResponse($responseData);
1136
		}
1137
1138
		return $response;
1139
	}
1140
1141
	public function subsonicErrorResponse($errorCode, $errorMessage) {
1142
		return $this->subsonicResponse([
1143
				'error' => [
1144
					'code' => $errorCode,
1145
					'message' => $errorMessage
1146
				]
1147
			], 'failed');
1148
	}
1149
}
1150