Passed
Push — master ( 40b137...b03450 )
by Pauli
02:02
created

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

522
		$this->trackBusinessLayer->setStarred(/** @scrutinizer ignore-type */ $targetIds['tracks'], $this->userId);
Loading history...
523
		$this->albumBusinessLayer->setStarred($targetIds['albums'], $this->userId);
524
		$this->artistBusinessLayer->setStarred($targetIds['artists'], $this->userId);
525
526
		return $this->subsonicResponse([]);
527
	}
528
529
	/**
530
	 * @SubsonicAPI
531
	 */
532
	private function unstar() {
533
		$targetIds = $this->getStarringParameters();
534
535
		$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

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