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

SubsonicController::getAlbumList()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 5
Bugs 0 Features 0
Metric Value
cc 1
eloc 3
nc 1
nop 0
dl 0
loc 4
ccs 0
cts 4
cp 0
crap 2
rs 10
c 5
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, 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