Passed
Pull Request — master (#875)
by Pauli
05:31 queued 02:43
created

SubsonicController::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 44
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 1
eloc 21
nc 1
nop 22
dl 0
loc 44
rs 9.584
c 2
b 0
f 0

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 declare(strict_types=1);
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 - 2021
11
 */
12
13
namespace OCA\Music\Controller;
14
15
use \OCP\AppFramework\Controller;
16
use \OCP\AppFramework\Http\DataDisplayResponse;
17
use \OCP\AppFramework\Http\JSONResponse;
18
use \OCP\AppFramework\Http\RedirectResponse;
19
use \OCP\Files\File;
20
use \OCP\Files\Folder;
21
use \OCP\IRequest;
22
use \OCP\IUserManager;
23
use \OCP\IURLGenerator;
24
25
use \OCA\Music\AppFramework\BusinessLayer\BusinessLayerException;
26
use \OCA\Music\AppFramework\Core\Logger;
27
use \OCA\Music\AppFramework\Utility\MethodAnnotationReader;
28
29
use \OCA\Music\BusinessLayer\AlbumBusinessLayer;
30
use \OCA\Music\BusinessLayer\ArtistBusinessLayer;
31
use \OCA\Music\BusinessLayer\BookmarkBusinessLayer;
32
use \OCA\Music\BusinessLayer\GenreBusinessLayer;
33
use \OCA\Music\BusinessLayer\Library;
34
use \OCA\Music\BusinessLayer\PlaylistBusinessLayer;
35
use \OCA\Music\BusinessLayer\PodcastChannelBusinessLayer;
36
use \OCA\Music\BusinessLayer\PodcastEpisodeBusinessLayer;
37
use \OCA\Music\BusinessLayer\RadioStationBusinessLayer;
38
use \OCA\Music\BusinessLayer\TrackBusinessLayer;
39
40
use \OCA\Music\Db\Album;
41
use \OCA\Music\Db\Artist;
42
use \OCA\Music\Db\Bookmark;
43
use \OCA\Music\Db\Genre;
44
use \OCA\Music\Db\Playlist;
45
use \OCA\Music\Db\PodcastEpisode;
46
use \OCA\Music\Db\SortBy;
47
use \OCA\Music\Db\Track;
48
49
use \OCA\Music\Http\FileResponse;
50
use \OCA\Music\Http\FileStreamResponse;
51
use \OCA\Music\Http\XmlResponse;
52
53
use \OCA\Music\Middleware\SubsonicException;
54
55
use \OCA\Music\Utility\CoverHelper;
56
use \OCA\Music\Utility\DetailsHelper;
57
use \OCA\Music\Utility\LastfmService;
58
use \OCA\Music\Utility\PodcastService;
59
use \OCA\Music\Utility\Random;
60
use \OCA\Music\Utility\UserMusicFolder;
61
use \OCA\Music\Utility\Util;
62
63
class SubsonicController extends Controller {
64
	const API_VERSION = '1.11.0';
65
66
	private $albumBusinessLayer;
67
	private $artistBusinessLayer;
68
	private $bookmarkBusinessLayer;
69
	private $genreBusinessLayer;
70
	private $playlistBusinessLayer;
71
	private $podcastChannelBusinessLayer;
72
	private $podcastEpisodeBusinessLayer;
73
	private $radioStationBusinessLayer;
74
	private $trackBusinessLayer;
75
	private $library;
76
	private $urlGenerator;
77
	private $userManager;
78
	private $userMusicFolder;
79
	private $l10n;
80
	private $coverHelper;
81
	private $detailsHelper;
82
	private $lastfmService;
83
	private $podcastService;
84
	private $random;
85
	private $logger;
86
	private $userId;
87
	private $format;
88
	private $callback;
89
90
	public function __construct($appname,
91
								IRequest $request,
92
								$l10n,
93
								IURLGenerator $urlGenerator,
94
								IUserManager $userManager,
95
								AlbumBusinessLayer $albumBusinessLayer,
96
								ArtistBusinessLayer $artistBusinessLayer,
97
								BookmarkBusinessLayer $bookmarkBusinessLayer,
98
								GenreBusinessLayer $genreBusinessLayer,
99
								PlaylistBusinessLayer $playlistBusinessLayer,
100
								PodcastChannelBusinessLayer $podcastChannelBusinessLayer,
101
								PodcastEpisodeBusinessLayer $podcastEpisodeBusinessLayer,
102
								RadioStationBusinessLayer $radioStationBusinessLayer,
103
								TrackBusinessLayer $trackBusinessLayer,
104
								Library $library,
105
								UserMusicFolder $userMusicFolder,
106
								CoverHelper $coverHelper,
107
								DetailsHelper $detailsHelper,
108
								LastfmService $lastfmService,
109
								PodcastService $podcastService,
110
								Random $random,
111
								Logger $logger) {
112
		parent::__construct($appname, $request);
113
114
		$this->albumBusinessLayer = $albumBusinessLayer;
115
		$this->artistBusinessLayer = $artistBusinessLayer;
116
		$this->bookmarkBusinessLayer = $bookmarkBusinessLayer;
117
		$this->genreBusinessLayer = $genreBusinessLayer;
118
		$this->playlistBusinessLayer = $playlistBusinessLayer;
119
		$this->podcastChannelBusinessLayer = $podcastChannelBusinessLayer;
120
		$this->podcastEpisodeBusinessLayer = $podcastEpisodeBusinessLayer;
121
		$this->radioStationBusinessLayer = $radioStationBusinessLayer;
122
		$this->trackBusinessLayer = $trackBusinessLayer;
123
		$this->library = $library;
124
		$this->urlGenerator = $urlGenerator;
125
		$this->userManager = $userManager;
126
		$this->l10n = $l10n;
127
		$this->userMusicFolder = $userMusicFolder;
128
		$this->coverHelper = $coverHelper;
129
		$this->detailsHelper = $detailsHelper;
130
		$this->lastfmService = $lastfmService;
131
		$this->podcastService = $podcastService;
132
		$this->random = $random;
133
		$this->logger = $logger;
134
	}
135
136
	/**
137
	 * Called by the middleware to set the reponse format to be used
138
	 * @param string $format Response format: xml/json/jsonp
139
	 * @param string|null $callback Function name to use if the @a $format is 'jsonp'
140
	 */
141
	public function setResponseFormat(string $format, string $callback = null) {
142
		$this->format = $format;
143
		$this->callback = $callback;
144
	}
145
146
	/**
147
	 * Called by the middleware once the user credentials have been checked
148
	 * @param string $userId
149
	 */
150
	public function setAuthenticatedUser(string $userId) {
151
		$this->userId = $userId;
152
	}
153
154
	/**
155
	 * @NoAdminRequired
156
	 * @PublicPage
157
	 * @NoCSRFRequired
158
	 */
159
	public function handleRequest($method) {
160
		$this->logger->log("Subsonic request $method", 'debug');
161
162
		// Allow calling all methods with or without the postfix ".view"
163
		if (Util::endsWith($method, ".view")) {
164
			$method = \substr($method, 0, -\strlen(".view"));
165
		}
166
167
		// Allow calling any functions annotated to be part of the API
168
		if (\method_exists($this, $method)) {
169
			$annotationReader = new MethodAnnotationReader($this, $method);
170
			if ($annotationReader->hasAnnotation('SubsonicAPI')) {
171
				return $this->$method();
172
			}
173
		}
174
175
		$this->logger->log("Request $method not supported", 'warn');
176
		return $this->subsonicErrorResponse(70, "Requested action $method is not supported");
177
	}
178
179
	/* -------------------------------------------------------------------------
180
	 * REST API methods
181
	 *------------------------------------------------------------------------*/
182
183
	/**
184
	 * @SubsonicAPI
185
	 */
186
	private function ping() {
187
		return $this->subsonicResponse([]);
188
	}
189
190
	/**
191
	 * @SubsonicAPI
192
	 */
193
	private function getLicense() {
194
		return $this->subsonicResponse([
195
			'license' => [
196
				'valid' => 'true'
197
			]
198
		]);
199
	}
200
201
	/**
202
	 * @SubsonicAPI
203
	 */
204
	private function getMusicFolders() {
205
		// Only single root folder is supported
206
		return $this->subsonicResponse([
207
			'musicFolders' => ['musicFolder' => [
208
				['id' => 'artists', 'name' => $this->l10n->t('Artists')],
209
				['id' => 'folders', 'name' => $this->l10n->t('Folders')]
210
			]]
211
		]);
212
	}
213
214
	/**
215
	 * @SubsonicAPI
216
	 */
217
	private function getIndexes() {
218
		$id = $this->request->getParam('musicFolderId');
219
220
		if ($id === 'folders') {
221
			return $this->getIndexesForFolders();
222
		} else {
223
			return $this->getIndexesForArtists();
224
		}
225
	}
226
227
	/**
228
	 * @SubsonicAPI
229
	 */
230
	private function getMusicDirectory() {
231
		$id = $this->getRequiredParam('id');
232
233
		if (Util::startsWith($id, 'folder-')) {
234
			return $this->getMusicDirectoryForFolder($id);
235
		} elseif (Util::startsWith($id, 'artist-')) {
236
			return $this->getMusicDirectoryForArtist($id);
237
		} elseif (Util::startsWith($id, 'album-')) {
238
			return $this->getMusicDirectoryForAlbum($id);
239
		} elseif (Util::startsWith($id, 'podcast_channel-')) {
240
			return $this->getMusicDirectoryForPodcastChannel($id);
241
		} else {
242
			throw new SubsonicException("Unsupported id format $id");
243
		}
244
	}
245
246
	/**
247
	 * @SubsonicAPI
248
	 */
249
	private function getAlbumList() {
250
		$albums = $this->albumsForGetAlbumList();
251
		return $this->subsonicResponse(['albumList' =>
252
				['album' => \array_map([$this, 'albumToOldApi'], $albums)]
253
		]);
254
	}
255
256
	/**
257
	 * @SubsonicAPI
258
	 */
259
	private function getAlbumList2() {
260
		/*
261
		 * According to the API specification, the difference between this and getAlbumList
262
		 * should be that this function would organize albums according the metadata while
263
		 * getAlbumList would organize them by folders. However, we organize by metadata
264
		 * also in getAlbumList, because that's more natural for the Music app and many/most
265
		 * clients do not support getAlbumList2.
266
		 */
267
		$albums = $this->albumsForGetAlbumList();
268
		return $this->subsonicResponse(['albumList2' =>
269
				['album' => \array_map([$this, 'albumToNewApi'], $albums)]
270
		]);
271
	}
272
273
	/**
274
	 * @SubsonicAPI
275
	 */
276
	private function getArtists() {
277
		return $this->getIndexesForArtists('artists');
278
	}
279
280
	/**
281
	 * @SubsonicAPI
282
	 */
283
	private function getArtist() {
284
		$id = $this->getRequiredParam('id');
285
		$artistId = self::ripIdPrefix($id); // get rid of 'artist-' prefix
286
287
		$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
288
		$albums = $this->albumBusinessLayer->findAllByArtist($artistId, $this->userId);
289
290
		$artistNode = $this->artistToApi($artist);
291
		$artistNode['album'] = \array_map([$this, 'albumToNewApi'], $albums);
292
293
		return $this->subsonicResponse(['artist' => $artistNode]);
294
	}
295
296
	/**
297
	 * @SubsonicAPI
298
	 */
299
	private function getArtistInfo() {
300
		return $this->doGetArtistInfo('artistInfo');
301
	}
302
303
	/**
304
	 * @SubsonicAPI
305
	 */
306
	private function getArtistInfo2() {
307
		return $this->doGetArtistInfo('artistInfo2');
308
	}
309
310
	/**
311
	 * @SubsonicAPI
312
	 */
313
	private function getSimilarSongs() {
314
		return $this->doGetSimilarSongs('similarSongs');
315
	}
316
317
	/**
318
	 * @SubsonicAPI
319
	 */
320
	private function getSimilarSongs2() {
321
		return $this->doGetSimilarSongs('similarSongs2');
322
	}
323
324
	/**
325
	 * @SubsonicAPI
326
	 */
327
	private function getAlbum() {
328
		$id = $this->getRequiredParam('id');
329
		$albumId = self::ripIdPrefix($id); // get rid of 'album-' prefix
330
331
		$album = $this->albumBusinessLayer->find($albumId, $this->userId);
332
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $this->userId);
333
334
		$albumNode = $this->albumToNewApi($album);
335
		$albumNode['song'] = \array_map(function ($track) use ($album) {
336
			$track->setAlbum($album);
337
			return $this->trackToApi($track);
338
		}, $tracks);
339
		return $this->subsonicResponse(['album' => $albumNode]);
340
	}
341
342
	/**
343
	 * @SubsonicAPI
344
	 */
345
	private function getSong() {
346
		$id = $this->getRequiredParam('id');
347
		$trackId = self::ripIdPrefix($id); // get rid of 'track-' prefix
348
		$track = $this->trackBusinessLayer->find($trackId, $this->userId);
349
350
		return $this->subsonicResponse(['song' => $this->trackToApi($track)]);
351
	}
352
353
	/**
354
	 * @SubsonicAPI
355
	 */
356
	private function getRandomSongs() {
357
		$size = (int)$this->request->getParam('size', 10);
358
		$size = \min($size, 500); // the API spec limits the maximum amount to 500
359
		$genre = $this->request->getParam('genre');
360
		$fromYear = $this->request->getParam('fromYear');
361
		$toYear = $this->request->getParam('toYear');
362
363
		if ($genre !== null) {
364
			$trackPool = $this->findTracksByGenre($genre);
365
		} else {
366
			$trackPool = $this->trackBusinessLayer->findAll($this->userId);
367
		}
368
369
		if ($fromYear !== null) {
370
			$trackPool = \array_filter($trackPool, function ($track) use ($fromYear) {
371
				return ($track->getYear() !== null && $track->getYear() >= $fromYear);
372
			});
373
		}
374
375
		if ($toYear !== null) {
376
			$trackPool = \array_filter($trackPool, function ($track) use ($toYear) {
377
				return ($track->getYear() !== null && $track->getYear() <= $toYear);
378
			});
379
		}
380
381
		$tracks = Random::pickItems($trackPool, $size);
382
383
		return $this->subsonicResponse(['randomSongs' =>
384
				['song' => \array_map([$this, 'trackToApi'], $tracks)]
385
		]);
386
	}
387
388
	/**
389
	 * @SubsonicAPI
390
	 */
391
	private function getCoverArt() {
392
		$id = $this->getRequiredParam('id');
393
		$size = (int)$this->request->getParam('size') ?: null; // null should not be int-casted
394
395
		$idParts = \explode('-', $id);
396
		$type = $idParts[0];
397
		$entityId = (int)($idParts[1]);
398
399
		if ($type == 'album') {
400
			$entity = $this->albumBusinessLayer->find($entityId, $this->userId);
401
		} elseif ($type == 'artist') {
402
			$entity = $this->artistBusinessLayer->find($entityId, $this->userId);
403
		} elseif ($type == 'podcast_channel') {
404
			$entity = $this->podcastService->getChannel($entityId, $this->userId, /*$includeEpisodes=*/ false);
405
		}
406
407
		if (!empty($entity)) {
408
			$rootFolder = $this->userMusicFolder->getFolder($this->userId);
409
			$coverData = $this->coverHelper->getCover($entity, $this->userId, $rootFolder, $size);
410
411
			if ($coverData !== null) {
412
				return new FileResponse($coverData);
413
			}
414
		}
415
416
		return $this->subsonicErrorResponse(70, "entity $id has no cover");
417
	}
418
419
	/**
420
	 * @SubsonicAPI
421
	 */
422
	private function getLyrics() {
423
		$artistPar = $this->request->getParam('artist');
424
		$titlePar = $this->request->getParam('title');
425
426
		$matches = $this->trackBusinessLayer->findAllByNameAndArtistName($titlePar, $artistPar, $this->userId);
427
		$matchingCount = \count($matches);
428
429
		if ($matchingCount === 0) {
430
			$this->logger->log("No matching track for title '$titlePar' and artist '$artistPar'", 'debug');
431
			return $this->subsonicResponse(['lyrics' => new \stdClass]);
432
		} else {
433
			if ($matchingCount > 1) {
434
				$this->logger->log("Found $matchingCount tracks matching title ".
435
									"'$titlePar' and artist '$artistPar'; using the first", 'debug');
436
			}
437
			$track = $matches[0];
438
439
			$artist = $this->artistBusinessLayer->find($track->getArtistId(), $this->userId);
440
			$rootFolder = $this->userMusicFolder->getFolder($this->userId);
441
			$lyrics = $this->detailsHelper->getLyrics($track->getFileId(), $rootFolder);
442
443
			return $this->subsonicResponse(['lyrics' => [
444
					'artist' => $artist->getNameString($this->l10n),
445
					'title' => $track->getTitle(),
446
					'value' => $lyrics
447
			]]);
448
		}
449
	}
450
451
	/**
452
	 * @SubsonicAPI
453
	 */
454
	private function stream() {
455
		// We don't support transcaoding, so 'stream' and 'download' act identically
456
		return $this->download();
457
	}
458
459
	/**
460
	 * @SubsonicAPI
461
	 */
462
	private function download() {
463
		$id = $this->getRequiredParam('id');
464
465
		$idParts = \explode('-', $id);
466
		$type = $idParts[0];
467
		$entityId = (int)($idParts[1]);
468
469
		if ($type === 'track') {
470
			$track = $this->trackBusinessLayer->find($entityId, $this->userId);
471
			$file = $this->getFilesystemNode($track->getFileId());
472
473
			if ($file instanceof File) {
474
				return new FileStreamResponse($file);
475
			} else {
476
				return $this->subsonicErrorResponse(70, 'file not found');
477
			}
478
		} elseif ($type === 'podcast_episode') {
479
			$episode = $this->podcastService->getEpisode($entityId, $this->userId);
480
			if ($episode instanceof PodcastEpisode) {
481
				return new RedirectResponse($episode->getStreamUrl());
482
			} else {
483
				return $this->subsonicErrorResponse(70, 'episode not found');
484
			}
485
		} else {
486
			return $this->subsonicErrorResponse(0, "id of type $type not supported");
487
		}
488
	}
489
490
	/**
491
	 * @SubsonicAPI
492
	 */
493
	private function search2() {
494
		$results = $this->doSearch();
495
		return $this->searchResponse('searchResult2', $results, /*$useNewApi=*/false);
496
	}
497
498
	/**
499
	 * @SubsonicAPI
500
	 */
501
	private function search3() {
502
		$results = $this->doSearch();
503
		return $this->searchResponse('searchResult3', $results, /*$useNewApi=*/true);
504
	}
505
506
	/**
507
	 * @SubsonicAPI
508
	 */
509
	private function getGenres() {
510
		$genres = $this->genreBusinessLayer->findAll($this->userId, SortBy::Name);
511
512
		return $this->subsonicResponse(['genres' =>
513
			[
514
				'genre' => \array_map(function ($genre) {
515
					return [
516
						'songCount' => $genre->getTrackCount(),
517
						'albumCount' => $genre->getAlbumCount(),
518
						'value' => $genre->getNameString($this->l10n)
519
					];
520
				},
521
				$genres)
522
			]
523
		]);
524
	}
525
526
	/**
527
	 * @SubsonicAPI
528
	 */
529
	private function getSongsByGenre() {
530
		$genre = $this->getRequiredParam('genre');
531
		$count = (int)$this->request->getParam('count', 10);
532
		$offset = (int)$this->request->getParam('offset', 0);
533
534
		$tracks = $this->findTracksByGenre($genre, $count, $offset);
535
536
		return $this->subsonicResponse(['songsByGenre' =>
537
			['song' => \array_map([$this, 'trackToApi'], $tracks)]
538
		]);
539
	}
540
541
	/**
542
	 * @SubsonicAPI
543
	 */
544
	private function getPlaylists() {
545
		$playlists = $this->playlistBusinessLayer->findAll($this->userId);
546
547
		return $this->subsonicResponse(['playlists' =>
548
			['playlist' => \array_map([$this, 'playlistToApi'], $playlists)]
549
		]);
550
	}
551
552
	/**
553
	 * @SubsonicAPI
554
	 */
555
	private function getPlaylist() {
556
		$id = (int)$this->getRequiredParam('id');
557
		$playlist = $this->playlistBusinessLayer->find($id, $this->userId);
558
		$tracks = $this->playlistBusinessLayer->getPlaylistTracks($id, $this->userId);
559
560
		$playlistNode = $this->playlistToApi($playlist);
561
		$playlistNode['entry'] = \array_map([$this, 'trackToApi'], $tracks);
562
563
		return $this->subsonicResponse(['playlist' => $playlistNode]);
564
	}
565
566
	/**
567
	 * @SubsonicAPI
568
	 */
569
	private function createPlaylist() {
570
		$name = $this->request->getParam('name');
571
		$listId = $this->request->getParam('playlistId');
572
		$songIds = $this->getRepeatedParam('songId');
573
		$songIds = \array_map('self::ripIdPrefix', $songIds);
574
575
		// If playlist ID has been passed, then this method actually updates an existing list instead of creating a new one.
576
		// The updating can't be used to rename the list, even if both ID and name are given (this is how the real Subsonic works, too).
577
		if (!empty($listId)) {
578
			$playlist = $this->playlistBusinessLayer->find((int)$listId, $this->userId);
579
		} elseif (!empty($name)) {
580
			$playlist = $this->playlistBusinessLayer->create($name, $this->userId);
581
		} else {
582
			throw new SubsonicException('Playlist ID or name must be specified.', 10);
583
		}
584
585
		$playlist->setTrackIdsFromArray($songIds);
586
		$this->playlistBusinessLayer->update($playlist);
587
588
		return $this->subsonicResponse([]);
589
	}
590
591
	/**
592
	 * @SubsonicAPI
593
	 */
594
	private function updatePlaylist() {
595
		$listId = (int)$this->getRequiredParam('playlistId');
596
		$newName = $this->request->getParam('name');
597
		$newComment = $this->request->getParam('comment');
598
		$songIdsToAdd = $this->getRepeatedParam('songIdToAdd');
599
		$songIdsToAdd = \array_map('self::ripIdPrefix', $songIdsToAdd);
600
		$songIndicesToRemove = $this->getRepeatedParam('songIndexToRemove');
601
602
		if (!empty($newName)) {
603
			$this->playlistBusinessLayer->rename($newName, $listId, $this->userId);
604
		}
605
606
		if ($newComment !== null) {
607
			$this->playlistBusinessLayer->setComment($newComment, $listId, $this->userId);
608
		}
609
610
		if (!empty($songIndicesToRemove)) {
611
			$this->playlistBusinessLayer->removeTracks($songIndicesToRemove, $listId, $this->userId);
612
		}
613
614
		if (!empty($songIdsToAdd)) {
615
			$this->playlistBusinessLayer->addTracks($songIdsToAdd, $listId, $this->userId);
616
		}
617
618
		return $this->subsonicResponse([]);
619
	}
620
621
	/**
622
	 * @SubsonicAPI
623
	 */
624
	private function deletePlaylist() {
625
		$id = (int)$this->getRequiredParam('id');
626
		$this->playlistBusinessLayer->delete($id, $this->userId);
627
		return $this->subsonicResponse([]);
628
	}
629
630
	/**
631
	 * @SubsonicAPI
632
	 */
633
	private function getInternetRadioStations() {
634
		$stations = $this->radioStationBusinessLayer->findAll($this->userId);
635
636
		return $this->subsonicResponse(['internetRadioStations' =>
637
				['internetRadioStation' => \array_map(function($station) {
638
					return [
639
						'id' => $station->getId(),
640
						'name' => $station->getName(),
641
						'streamUrl' => $station->getStreamUrl()
642
					];
643
				}, $stations)]
644
		]);
645
	}
646
647
	/**
648
	 * @SubsonicAPI
649
	 */
650
	private function getUser() {
651
		$username = $this->getRequiredParam('username');
652
653
		if ($username != $this->userId) {
654
			throw new SubsonicException("{$this->userId} is not authorized to get details for other users.", 50);
655
		}
656
657
		return $this->subsonicResponse([
658
			'user' => [
659
				'username' => $username,
660
				'email' => '',
661
				'scrobblingEnabled' => false,
662
				'adminRole' => false,
663
				'settingsRole' => false,
664
				'downloadRole' => true,
665
				'uploadRole' => false,
666
				'playlistRole' => true,
667
				'coverArtRole' => false,
668
				'commentRole' => false,
669
				'podcastRole' => true,
670
				'streamRole' => true,
671
				'jukeboxRole' => false,
672
				'shareRole' => false,
673
				'videoConversionRole' => false,
674
				'folder' => ['artists', 'folders'],
675
			]
676
		]);
677
	}
678
679
	/**
680
	 * @SubsonicAPI
681
	 */
682
	private function getUsers() {
683
		throw new SubsonicException("{$this->userId} is not authorized to get details for other users.", 50);
684
	}
685
686
	/**
687
	 * @SubsonicAPI
688
	 */
689
	private function getAvatar() {
690
		$username = $this->getRequiredParam('username');
691
		if ($username != $this->userId) {
692
			throw new SubsonicException("{$this->userId} is not authorized to get avatar for other users.", 50);
693
		}
694
695
		$image = $this->userManager->get($username)->getAvatarImage(150);
696
697
		if ($image !== null) {
698
			return new FileResponse(['content' => $image->data(), 'mimetype' => $image->mimeType()]);
699
		} else {
700
			return $this->subsonicErrorResponse(70, 'user has no avatar');
701
		}
702
	}
703
704
	/**
705
	 * @SubsonicAPI
706
	 */
707
	private function star() {
708
		$targetIds = $this->getStarringParameters();
709
710
		$this->trackBusinessLayer->setStarred($targetIds['tracks'], $this->userId);
711
		$this->albumBusinessLayer->setStarred($targetIds['albums'], $this->userId);
712
		$this->artistBusinessLayer->setStarred($targetIds['artists'], $this->userId);
713
		$this->podcastChannelBusinessLayer->setStarred($targetIds['podcast_channels'], $this->userId);
714
		$this->podcastEpisodeBusinessLayer->setStarred($targetIds['podcast_episodes'], $this->userId);
715
716
		return $this->subsonicResponse([]);
717
	}
718
719
	/**
720
	 * @SubsonicAPI
721
	 */
722
	private function unstar() {
723
		$targetIds = $this->getStarringParameters();
724
725
		$this->trackBusinessLayer->unsetStarred($targetIds['tracks'], $this->userId);
726
		$this->albumBusinessLayer->unsetStarred($targetIds['albums'], $this->userId);
727
		$this->artistBusinessLayer->unsetStarred($targetIds['artists'], $this->userId);
728
		$this->podcastChannelBusinessLayer->unsetStarred($targetIds['podcast_channels'], $this->userId);
729
		$this->podcastEpisodeBusinessLayer->unsetStarred($targetIds['podcast_episodes'], $this->userId);
730
731
		return $this->subsonicResponse([]);
732
	}
733
734
	/**
735
	 * @SubsonicAPI
736
	 */
737
	private function getStarred() {
738
		$starred = $this->doGetStarred();
739
		return $this->searchResponse('starred', $starred, /*$useNewApi=*/false);
740
	}
741
742
	/**
743
	 * @SubsonicAPI
744
	 */
745
	private function getStarred2() {
746
		$starred = $this->doGetStarred();
747
		return $this->searchResponse('starred2', $starred, /*$useNewApi=*/true);
748
	}
749
750
	/**
751
	 * @SubsonicAPI
752
	 */
753
	private function getVideos() {
754
		// Feature not supported, return an empty list
755
		return $this->subsonicResponse([
756
			'videos' => [
757
				'video' => []
758
			]
759
		]);
760
	}
761
762
	/**
763
	 * @SubsonicAPI
764
	 */
765
	private function getPodcasts() {
766
		$includeEpisodes = \filter_var($this->request->getParam('includeEpisodes', true), FILTER_VALIDATE_BOOLEAN);
767
		$id = $this->request->getParam('id');
768
769
		if ($id !== null) {
770
			$id = self::ripIdPrefix($id);
771
			$channel = $this->podcastService->getChannel($id, $this->userId, $includeEpisodes);
772
			if ($channel === null) {
773
				throw new SubsonicException('Requested channel not found', 70);
774
			}
775
			$channels = [$channel];
776
		} else {
777
			$channels = $this->podcastService->getAllChannels($this->userId, $includeEpisodes);
778
		}
779
780
		return $this->subsonicResponse([
781
			'podcasts' => [
782
				'channel' => Util::arrayMapMethod($channels, 'toSubsonicApi')
783
			]
784
		]);
785
	}
786
787
	/**
788
	 * @SubsonicAPI
789
	 */
790
	private function refreshPodcasts() {
791
		$this->podcastService->updateAllChannels($this->userId);
792
		return $this->subsonicResponse([]);
793
	}
794
795
	/**
796
	 * @SubsonicAPI
797
	 */
798
	private function createPodcastChannel() {
799
		$url = $this->getRequiredParam('url');
800
		$result = $this->podcastService->subscribe($url, $this->userId);
801
802
		switch ($result['status']) {
803
			case PodcastService::STATUS_OK:
804
				return $this->subsonicResponse([]);
805
			case PodcastService::STATUS_INVALID_URL:
806
				throw new SubsonicException("Invalid URL $url", 0);
807
			case PodcastService::STATUS_INVALID_RSS:
808
				throw new SubsonicException("The document at URL $url is not a valid podcast RSS feed", 0);
809
			case PodcastService::STATUS_ALREADY_EXISTS:
810
				throw new SubsonicException('User already has this podcast channel subscribed', 0);
811
			default:
812
				throw new SubsonicException("Unexpected status code {$result['status']}", 0);
813
		}
814
	}
815
816
	/**
817
	 * @SubsonicAPI
818
	 */
819
	private function deletePodcastChannel() {
820
		$id = $this->getRequiredParam('id');
821
		$id = self::ripIdPrefix($id);
822
		$status = $this->podcastService->unsubscribe($id, $this->userId);
823
824
		switch ($status) {
825
			case PodcastService::STATUS_OK:
826
				return $this->subsonicResponse([]);
827
			case PodcastService::STATUS_NOT_FOUND:
828
				throw new SubsonicException('Channel to be deleted not found', 70);
829
			default:
830
				throw new SubsonicException("Unexpected status code $status", 0);
831
		}
832
	}
833
834
	/**
835
	 * @SubsonicAPI
836
	 */
837
	private function getBookmarks() {
838
		$bookmarkNodes = [];
839
		$bookmarks = $this->bookmarkBusinessLayer->findAll($this->userId);
840
841
		foreach ($bookmarks as $bookmark) {
842
			$node = $bookmark->toSubsonicApi();
843
			$entryId = $bookmark->getEntryId();
844
			$type = $bookmark->getType();
845
846
			try {
847
				if ($type === Bookmark::TYPE_TRACK) {
848
					$node['entry'] = $this->trackToApi($this->trackBusinessLayer->find($entryId, $this->userId));
849
				} elseif ($type === Bookmark::TYPE_PODCAST_EPISODE) {
850
					$node['entry'] = $this->podcastEpisodeBusinessLayer->find($entryId, $this->userId)->toSubsonicApi();
851
				} else {
852
					$this->logger->log("Bookmark {$bookmark->getId()} had unexpected entry type $type", 'warn');
853
				}
854
				$bookmarkNodes[] = $node;
855
			} catch (BusinessLayerException $e) {
856
				$this->logger->log("Bookmarked entry with type $type and id $entryId not found", 'warn');
857
			}
858
		}
859
860
		return $this->subsonicResponse(['bookmarks' => ['bookmark' => $bookmarkNodes]]);
861
	}
862
863
	/**
864
	 * @SubsonicAPI
865
	 */
866
	private function createBookmark() {
867
		list($type, $id) = $this->getBookamrkIdParam();
868
		$this->bookmarkBusinessLayer->addOrUpdate(
869
				$this->userId,
870
				$type,
871
				$id,
872
				(int)$this->getRequiredParam('position'),
873
				$this->request->getParam('comment')
874
		);
875
		return $this->subsonicResponse([]);
876
	}
877
878
	/**
879
	 * @SubsonicAPI
880
	 */
881
	private function deleteBookmark() {
882
		list($type, $id) = $this->getBookamrkIdParam();
883
884
		$bookmark = $this->bookmarkBusinessLayer->findByEntry($type, $id, $this->userId);
885
		$this->bookmarkBusinessLayer->delete($bookmark->getId(), $this->userId);
886
887
		return $this->subsonicResponse([]);
888
	}
889
890
	/* -------------------------------------------------------------------------
891
	 * Helper methods
892
	 *------------------------------------------------------------------------*/
893
894
	private function getBookamrkIdParam() : array {
895
		$id = $this->getRequiredParam('id');
896
		list($typeName, $entityId) = \explode('-', $id);
897
898
		if ($typeName === 'track') {
899
			$type = Bookmark::TYPE_TRACK;
900
		} elseif ($typeName === 'podcast_episode') {
901
			$type = Bookmark::TYPE_PODCAST_EPISODE;
902
		} else {
903
			throw new SubsonicException("Unsupported ID format $id", 0);
904
		}
905
906
		return [$type, (int)$entityId];
907
	}
908
909
	private function getRequiredParam($paramName) {
910
		$param = $this->request->getParam($paramName);
911
912
		if ($param === null) {
913
			throw new SubsonicException("Required parameter '$paramName' missing", 10);
914
		}
915
916
		return $param;
917
	}
918
919
	/**
920
	 * Get parameters used in the `star` and `unstar` API methods
921
	 */
922
	private function getStarringParameters() {
923
		// album IDs from newer clients
924
		$albumIds = $this->getRepeatedParam('albumId');
925
		$albumIds = \array_map('self::ripIdPrefix', $albumIds);
926
927
		// artist IDs from newer clients
928
		$artistIds = $this->getRepeatedParam('artistId');
929
		$artistIds = \array_map('self::ripIdPrefix', $artistIds);
930
931
		// song IDs from newer clients and song/folder/album/artist IDs from older clients
932
		// also podcast IDs may come here; that is not documented part of the API but at least DSub does that
933
		$ids = $this->getRepeatedParam('id');
934
935
		$trackIds = [];
936
		$channelIds = [];
937
		$episodeIds = [];
938
939
		foreach ($ids as $prefixedId) {
940
			$parts = \explode('-', $prefixedId);
941
			$type = $parts[0];
942
			$id = (int)$parts[1];
943
944
			if ($type == 'track') {
945
				$trackIds[] = $id;
946
			} elseif ($type == 'album') {
947
				$albumIds[] = $id;
948
			} elseif ($type == 'artist') {
949
				$artistIds[] = $id;
950
			} elseif ($type == 'podcast_channel') {
951
				$channelIds[] = $id;
952
			} elseif ($type == 'podcast_episode') {
953
				$episodeIds[] = $id;
954
			} elseif ($type == 'folder') {
955
				throw new SubsonicException('Starring folders is not supported', 0);
956
			} else {
957
				throw new SubsonicException("Unexpected ID format: $prefixedId", 0);
958
			}
959
		}
960
961
		return [
962
			'tracks' => $trackIds,
963
			'albums' => $albumIds,
964
			'artists' => $artistIds,
965
			'podcast_channels' => $channelIds,
966
			'podcast_episodes' => $episodeIds
967
		];
968
	}
969
970
	/**
971
	 * Get values for parameter which may be present multiple times in the query
972
	 * string or POST data.
973
	 * @param string $paramName
974
	 * @return string[]
975
	 */
976
	private function getRepeatedParam($paramName) {
977
		// We can't use the IRequest object nor $_GET and $_POST to get the data
978
		// because all of these are based to the idea of unique parameter names.
979
		// If the same name is repeated, only the last value is saved. Hence, we
980
		// need to parse the raw data manually.
981
982
		// query string is always present (although it could be empty)
983
		$values = $this->parseRepeatedKeyValues($paramName, $_SERVER['QUERY_STRING']);
984
985
		// POST data is available if the method is POST
986
		if ($this->request->getMethod() == 'POST') {
987
			$values = \array_merge($values,
988
					$this->parseRepeatedKeyValues($paramName, \file_get_contents('php://input')));
989
		}
990
991
		return $values;
992
	}
993
994
	/**
995
	 * Parse a string like "someKey=value1&someKey=value2&anotherKey=valueA&someKey=value3"
996
	 * and return an array of values for the given key
997
	 * @param string $key
998
	 * @param string $data
999
	 */
1000
	private function parseRepeatedKeyValues($key, $data) {
1001
		$result = [];
1002
1003
		$keyValuePairs = \explode('&', $data);
1004
1005
		foreach ($keyValuePairs as $pair) {
1006
			$keyAndValue = \explode('=', $pair);
1007
1008
			if ($keyAndValue[0] == $key) {
1009
				$result[] = $keyAndValue[1];
1010
			}
1011
		}
1012
1013
		return $result;
1014
	}
1015
1016
	private function getFilesystemNode($id) {
1017
		$rootFolder = $this->userMusicFolder->getFolder($this->userId);
1018
		$nodes = $rootFolder->getById($id);
1019
1020
		if (\count($nodes) != 1) {
1021
			throw new SubsonicException('file not found', 70);
1022
		}
1023
1024
		return $nodes[0];
1025
	}
1026
1027
	private function getIndexesForFolders() {
1028
		$rootFolder = $this->userMusicFolder->getFolder($this->userId);
1029
1030
		return $this->subsonicResponse(['indexes' => ['index' => [
1031
			['name' => '*',
1032
			'artist' => [['id' => 'folder-' . $rootFolder->getId(), 'name' => $rootFolder->getName()]]]
1033
		]]]);
1034
	}
1035
1036
	private function getMusicDirectoryForFolder($id) {
1037
		$folderId = self::ripIdPrefix($id); // get rid of 'folder-' prefix
1038
		$folder = $this->getFilesystemNode($folderId);
1039
1040
		if (!($folder instanceof Folder)) {
1041
			throw new SubsonicException("$id is not a valid folder", 70);
1042
		}
1043
1044
		$nodes = $folder->getDirectoryListing();
1045
		$subFolders = \array_filter($nodes, function ($n) {
1046
			return $n instanceof Folder;
1047
		});
1048
		$tracks = $this->trackBusinessLayer->findAllByFolder($folderId, $this->userId);
1049
1050
		// A folder may contain thousands of audio files, and getting album data
1051
		// for each of those individually would take a lot of time and great many DB queries.
1052
		// To prevent having to do this in `trackToApi`, we fetch all the albums in one go.
1053
		$this->injectAlbumsToTracks($tracks);
1054
1055
		$children = \array_merge(
1056
			\array_map([$this, 'folderToApi'], $subFolders),
1057
			\array_map([$this, 'trackToApi'], $tracks)
1058
		);
1059
1060
		$content = [
1061
			'directory' => [
1062
				'id' => $id,
1063
				'name' => $folder->getName(),
1064
				'child' => $children
1065
			]
1066
		];
1067
1068
		// Parent folder ID is included if and only if the parent folder is not the top level
1069
		$rootFolderId = $this->userMusicFolder->getFolder($this->userId)->getId();
1070
		$parentFolderId = $folder->getParent()->getId();
1071
		if ($rootFolderId != $parentFolderId) {
1072
			$content['parent'] = 'folder-' . $parentFolderId;
1073
		}
1074
1075
		return $this->subsonicResponse($content);
1076
	}
1077
1078
	private function injectAlbumsToTracks(&$tracks) {
1079
		$albumIds = [];
1080
1081
		// get unique album IDs
1082
		foreach ($tracks as $track) {
1083
			$albumIds[$track->getAlbumId()] = 1;
1084
		}
1085
		$albumIds = \array_keys($albumIds);
1086
1087
		// get the corresponding entities from the business layer
1088
		$albums = $this->albumBusinessLayer->findById($albumIds, $this->userId);
1089
1090
		// create hash tables "id => entity" for the albums for fast access
1091
		$albumMap = Util::createIdLookupTable($albums);
1092
1093
		// finally, set the references on the tracks
1094
		foreach ($tracks as &$track) {
1095
			$track->setAlbum($albumMap[$track->getAlbumId()]);
1096
		}
1097
	}
1098
1099
	private function getIndexesForArtists($rootElementName = 'indexes') {
1100
		$artists = $this->artistBusinessLayer->findAllHavingAlbums($this->userId, SortBy::Name);
1101
1102
		$indexes = [];
1103
		foreach ($artists as $artist) {
1104
			$indexes[$artist->getIndexingChar()][] = $this->artistToApi($artist);
1105
		}
1106
1107
		$result = [];
1108
		foreach ($indexes as $indexChar => $bucketArtists) {
1109
			$result[] = ['name' => $indexChar, 'artist' => $bucketArtists];
1110
		}
1111
1112
		return $this->subsonicResponse([$rootElementName => ['index' => $result]]);
1113
	}
1114
1115
	private function getMusicDirectoryForArtist($id) {
1116
		$artistId = self::ripIdPrefix($id); // get rid of 'artist-' prefix
1117
1118
		$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
1119
		$albums = $this->albumBusinessLayer->findAllByArtist($artistId, $this->userId);
1120
1121
		return $this->subsonicResponse([
1122
			'directory' => [
1123
				'id' => $id,
1124
				'name' => $artist->getNameString($this->l10n),
1125
				'child' => \array_map([$this, 'albumToOldApi'], $albums)
1126
			]
1127
		]);
1128
	}
1129
1130
	private function getMusicDirectoryForAlbum($id) {
1131
		$albumId = self::ripIdPrefix($id); // get rid of 'album-' prefix
1132
1133
		$album = $this->albumBusinessLayer->find($albumId, $this->userId);
1134
		$albumName = $album->getNameString($this->l10n);
1135
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $this->userId);
1136
1137
		return $this->subsonicResponse([
1138
			'directory' => [
1139
				'id' => $id,
1140
				'parent' => 'artist-' . $album->getAlbumArtistId(),
1141
				'name' => $albumName,
1142
				'child' => \array_map(function ($track) use ($album) {
1143
					$track->setAlbum($album);
1144
					return $this->trackToApi($track);
1145
				}, $tracks)
1146
			]
1147
		]);
1148
	}
1149
1150
	private function getMusicDirectoryForPodcastChannel($id) {
1151
		$channelId = self::ripIdPrefix($id); // get rid of 'podcast_channel-' prefix
1152
		$channel = $this->podcastService->getChannel($channelId, $this->userId, /*$includeEpisodes=*/ true);
1153
1154
		if ($channel === null) {
1155
			throw new SubsonicException("Podcast channel $channelId not found", 0);
1156
		}
1157
1158
		return $this->subsonicResponse([
1159
			'directory' => [
1160
				'id' => $id,
1161
				'name' => $channel->getTitle(),
1162
				'child' => Util::arrayMapMethod($channel->getEpisodes(), 'toSubsonicApi')
1163
			]
1164
		]);
1165
	}
1166
1167
	/**
1168
	 * @param Folder $folder
1169
	 * @return array
1170
	 */
1171
	private function folderToApi($folder) {
1172
		return [
1173
			'id' => 'folder-' . $folder->getId(),
1174
			'title' => $folder->getName(),
1175
			'isDir' => true
1176
		];
1177
	}
1178
1179
	/**
1180
	 * @param Artist $artist
1181
	 * @return array
1182
	 */
1183
	private function artistToApi($artist) {
1184
		$id = $artist->getId();
1185
		$result = [
1186
			'name' => $artist->getNameString($this->l10n),
1187
			'id' => $id ? ('artist-' . $id) : '-1', // getArtistInfo may show artists without ID
1188
			'albumCount' => $id ? $this->albumBusinessLayer->countByArtist($id) : 0
1189
		];
1190
1191
		if (!empty($artist->getCoverFileId())) {
1192
			$result['coverArt'] = $result['id'];
1193
		}
1194
1195
		if (!empty($artist->getStarred())) {
1196
			$result['starred'] = Util::formatZuluDateTime($artist->getStarred());
1197
		}
1198
1199
		return $result;
1200
	}
1201
1202
	/**
1203
	 * The "old API" format is used e.g. in getMusicDirectory and getAlbumList
1204
	 * @param Album $album
1205
	 * @return array
1206
	 */
1207
	private function albumToOldApi($album) {
1208
		$result = $this->albumCommonApiFields($album);
1209
1210
		$result['parent'] = 'artist-' . $album->getAlbumArtistId();
1211
		$result['title'] = $album->getNameString($this->l10n);
1212
		$result['isDir'] = true;
1213
1214
		return $result;
1215
	}
1216
1217
	/**
1218
	 * The "new API" format is used e.g. in getAlbum and getAlbumList2
1219
	 * @param Album $album
1220
	 * @return array
1221
	 */
1222
	private function albumToNewApi($album) {
1223
		$result = $this->albumCommonApiFields($album);
1224
1225
		$result['artistId'] = 'artist-' . $album->getAlbumArtistId();
1226
		$result['name'] = $album->getNameString($this->l10n);
1227
		$result['songCount'] = $this->trackBusinessLayer->countByAlbum($album->getId());
1228
		$result['duration'] = $this->trackBusinessLayer->totalDurationOfAlbum($album->getId());
1229
1230
		return $result;
1231
	}
1232
1233
	private function albumCommonApiFields($album) {
1234
		$result = [
1235
			'id' => 'album-' . $album->getId(),
1236
			'artist' => $album->getAlbumArtistNameString($this->l10n),
1237
			'created' => Util::formatZuluDateTime($album->getCreated())
1238
		];
1239
1240
		if (!empty($album->getCoverFileId())) {
1241
			$result['coverArt'] = 'album-' . $album->getId();
1242
		}
1243
1244
		if (!empty($album->getStarred())) {
1245
			$result['starred'] = Util::formatZuluDateTime($album->getStarred());
1246
		}
1247
1248
		if (!empty($album->getGenres())) {
1249
			$result['genre'] = \implode(', ', \array_map(function (Genre $genre) {
1250
				return $genre->getNameString($this->l10n);
1251
			}, $album->getGenres()));
1252
		}
1253
1254
		if (!empty($album->getYears())) {
1255
			$result['year'] = $album->yearToAPI();
1256
		}
1257
1258
		return $result;
1259
	}
1260
1261
	/**
1262
	 * The same API format is used both on "old" and "new" API methods. The "new" API adds some
1263
	 * new fields for the songs, but providing some extra fields shouldn't be a problem for the
1264
	 * older clients.
1265
	 * @param Track $track If the track entity has no album references set, then it is automatically
1266
	 *                     fetched from the AlbumBusinessLayer module.
1267
	 * @return array
1268
	 */
1269
	private function trackToApi($track) {
1270
		$albumId = $track->getAlbumId();
1271
1272
		$album = $track->getAlbum();
1273
		if ($album === null && $albumId !== null) {
1274
			$album = $this->albumBusinessLayer->findOrDefault($albumId, $this->userId);
1275
			$track->setAlbum($album);
1276
		}
1277
1278
		$result = [
1279
			'id' => 'track-' . $track->getId(),
1280
			'parent' => 'album-' . $albumId,
1281
			//'discNumber' => $track->getDisk(), // not supported on any of the tested clients => adjust track number instead
1282
			'title' => $track->getTitle() ?? '',
1283
			'artist' => $track->getArtistNameString($this->l10n),
1284
			'isDir' => false,
1285
			'album' => $track->getAlbumNameString($this->l10n),
1286
			'year' => $track->getYear(),
1287
			'size' => $track->getSize() ?? 0,
1288
			'contentType' => $track->getMimetype() ?? '',
1289
			'suffix' => $track->getFileExtension(),
1290
			'duration' => $track->getLength() ?: 0,
1291
			'bitRate' => empty($track->getBitrate()) ? 0 : (int)\round($track->getBitrate()/1000), // convert bps to kbps
1292
			//'path' => '',
1293
			'isVideo' => false,
1294
			'albumId' => 'album-' . $albumId,
1295
			'artistId' => 'artist-' . $track->getArtistId(),
1296
			'type' => 'music',
1297
			'created' => Util::formatZuluDateTime($track->getCreated())
1298
		];
1299
1300
		if ($album !== null && !empty($album->getCoverFileId())) {
1301
			$result['coverArt'] = 'album-' . $album->getId();
1302
		}
1303
1304
		$trackNumber = $track->getAdjustedTrackNumber();
1305
		if ($trackNumber !== null) {
1306
			$result['track'] = $trackNumber;
1307
		}
1308
1309
		if (!empty($track->getStarred())) {
1310
			$result['starred'] = Util::formatZuluDateTime($track->getStarred());
1311
		}
1312
1313
		if (!empty($track->getGenreId())) {
1314
			$result['genre'] = $track->getGenreNameString($this->l10n);
1315
		}
1316
1317
		return $result;
1318
	}
1319
1320
	/**
1321
	 * @param Playlist $playlist
1322
	 * @return array
1323
	 */
1324
	private function playlistToApi($playlist) {
1325
		return [
1326
			'id' => $playlist->getId(),
1327
			'name' => $playlist->getName(),
1328
			'owner' => $this->userId,
1329
			'public' => false,
1330
			'songCount' => $playlist->getTrackCount(),
1331
			'duration' => $this->playlistBusinessLayer->getDuration($playlist->getId(), $this->userId),
1332
			'comment' => $playlist->getComment() ?: '',
1333
			'created' => Util::formatZuluDateTime($playlist->getCreated()),
1334
			'changed' => Util::formatZuluDateTime($playlist->getUpdated())
1335
			//'coverArt' => '' // added in API 1.11.0 but is optional even there
1336
		];
1337
	}
1338
1339
	/**
1340
	 * Common logic for getAlbumList and getAlbumList2
1341
	 * @return Album[]
1342
	 */
1343
	private function albumsForGetAlbumList() {
1344
		$type = $this->getRequiredParam('type');
1345
		$size = (int)$this->request->getParam('size', 10);
1346
		$size = \min($size, 500); // the API spec limits the maximum amount to 500
1347
		$offset = (int)$this->request->getParam('offset', 0);
1348
1349
		$albums = [];
1350
1351
		switch ($type) {
1352
			case 'random':
1353
				$allAlbums = $this->albumBusinessLayer->findAll($this->userId);
1354
				$indices = $this->random->getIndices(\count($allAlbums), $offset, $size, $this->userId, 'subsonic_albums');
1355
				$albums = Util::arrayMultiGet($allAlbums, $indices);
1356
				break;
1357
			case 'starred':
1358
				$albums = $this->albumBusinessLayer->findAllStarred($this->userId, $size, $offset);
1359
				break;
1360
			case 'alphabeticalByName':
1361
				$albums = $this->albumBusinessLayer->findAll($this->userId, SortBy::Name, $size, $offset);
1362
				break;
1363
			case 'alphabeticalByArtist':
1364
				$albums = $this->albumBusinessLayer->findAll($this->userId, SortBy::Parent, $size, $offset);
1365
				break;
1366
			case 'byGenre':
1367
				$genre = $this->getRequiredParam('genre');
1368
				$albums = $this->findAlbumsByGenre($genre, $size, $offset);
1369
				break;
1370
			case 'byYear':
1371
				$fromYear = (int)$this->getRequiredParam('fromYear');
1372
				$toYear = (int)$this->getRequiredParam('toYear');
1373
				$albums = $this->albumBusinessLayer->findAllByYearRange($fromYear, $toYear, $this->userId, $size, $offset);
1374
				break;
1375
			case 'newest':
1376
				$albums = $this->albumBusinessLayer->findAll($this->userId, SortBy::Newest, $size, $offset);
1377
				break;
1378
			case 'highest':
1379
			case 'frequent':
1380
			case 'recent':
1381
			default:
1382
				$this->logger->log("Album list type '$type' is not supported", 'debug');
1383
				break;
1384
		}
1385
1386
		return $albums;
1387
	}
1388
1389
	/**
1390
	 * Common logic for getArtistInfo and getArtistInfo2
1391
	 */
1392
	private function doGetArtistInfo($rootName) {
1393
		$content = [];
1394
1395
		$id = $this->getRequiredParam('id');
1396
1397
		// This function may be called with a folder ID instead of an artist ID in case
1398
		// the library is being browsed by folders. In that case, return an empty response.
1399
		if (Util::startsWith($id, 'artist')) {
1400
			$artistId = self::ripIdPrefix($id); // get rid of 'artist-' prefix
1401
			$includeNotPresent = $this->request->getParam('includeNotPresent', false);
1402
			$includeNotPresent = \filter_var($includeNotPresent, FILTER_VALIDATE_BOOLEAN);
1403
1404
			$info = $this->lastfmService->getArtistInfo($artistId, $this->userId);
1405
1406
			if (isset($info['artist'])) {
1407
				$content = [
1408
					'biography' => $info['artist']['bio']['summary'],
1409
					'lastFmUrl' => $info['artist']['url'],
1410
					'musicBrainzId' => $info['artist']['mbid']
1411
				];
1412
1413
				$similarArtists = $this->lastfmService->getSimilarArtists($artistId, $this->userId, $includeNotPresent);
1414
				$content['similarArtist'] = \array_map([$this, 'artistToApi'], $similarArtists);
1415
			}
1416
1417
			$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
1418
			if ($artist->getCoverFileId() !== null) {
1419
				$par = $this->request->getParams();
1420
				$url = $this->urlGenerator->linkToRouteAbsolute('music.subsonic.handleRequest', ['method' => 'getCoverArt'])
1421
						. "?u={$par['u']}&p={$par['p']}&v={$par['v']}&c={$par['c']}&id=$id";
1422
				$content['largeImageUrl'] = [$url];
1423
			}
1424
		}
1425
1426
		// This method is unusual in how it uses non-attribute elements in the response. On the other hand,
1427
		// all the details of the <similarArtist> elements are rendered as attributes. List those separately.
1428
		$attributeKeys = ['name', 'id', 'albumCount', 'coverArt', 'starred'];
1429
1430
		return $this->subsonicResponse([$rootName => $content], $attributeKeys);
1431
	}
1432
1433
	/**
1434
	 * Common logic for getSimilarSongs and getSimilarSongs2
1435
	 */
1436
	private function doGetSimilarSongs($rootName) {
1437
		$id = $this->getRequiredParam('id');
1438
		$count = (int)$this->request->getParam('count', 50);
1439
1440
		if (Util::startsWith($id, 'artist')) {
1441
			$artistId = self::ripIdPrefix($id);
1442
		} elseif (Util::startsWith($id, 'album')) {
1443
			$albumId = self::ripIdPrefix($id);
1444
			$artistId = $this->albumBusinessLayer->find($albumId, $this->userId)->getAlbumArtistId();
1445
		} elseif (Util::startsWith($id, 'track')) {
1446
			$trackId = self::ripIdPrefix($id);
1447
			$artistId = $this->trackBusinessLayer->find($trackId, $this->userId)->getArtistId();
1448
		} else {
1449
			throw new SubsonicException("Id $id has a type not supported on getSimilarSongs", 0);
1450
		}
1451
1452
		$artists = $this->lastfmService->getSimilarArtists($artistId, $this->userId);
1453
		$artists[] = $this->artistBusinessLayer->find($artistId, $this->userId);
1454
1455
		// Get all songs by the found artists
1456
		$songs = [];
1457
		foreach ($artists as $artist) {
1458
			$songs = \array_merge($songs, $this->trackBusinessLayer->findAllByArtist($artist->getId(), $this->userId));
1459
		}
1460
1461
		// Randomly select the desired number of songs
1462
		$songs = $this->random->pickItems($songs, $count);
1463
1464
		return $this->subsonicResponse([$rootName => [
1465
				'song' => \array_map([$this, 'trackToApi'], $songs)
1466
		]]);
1467
	}
1468
1469
	/**
1470
	 * Common logic for search2 and search3
1471
	 * @return array with keys 'artists', 'albums', and 'tracks'
1472
	 */
1473
	private function doSearch() {
1474
		$query = $this->getRequiredParam('query');
1475
		$artistCount = (int)$this->request->getParam('artistCount', 20);
1476
		$artistOffset = (int)$this->request->getParam('artistOffset', 0);
1477
		$albumCount = (int)$this->request->getParam('albumCount', 20);
1478
		$albumOffset = (int)$this->request->getParam('albumOffset', 0);
1479
		$songCount = (int)$this->request->getParam('songCount', 20);
1480
		$songOffset = (int)$this->request->getParam('songOffset', 0);
1481
1482
		if (empty($query)) {
1483
			throw new SubsonicException("The 'query' argument is mandatory", 10);
1484
		}
1485
1486
		return [
1487
			'artists' => $this->artistBusinessLayer->findAllByName($query, $this->userId, true, $artistCount, $artistOffset),
1488
			'albums' => $this->albumBusinessLayer->findAllByName($query, $this->userId, true, $albumCount, $albumOffset),
1489
			'tracks' => $this->trackBusinessLayer->findAllByName($query, $this->userId, true, $songCount, $songOffset)
1490
		];
1491
	}
1492
1493
	/**
1494
	 * Common logic for getStarred and getStarred2
1495
	 * @return array
1496
	 */
1497
	private function doGetStarred() {
1498
		return [
1499
			'artists' => $this->artistBusinessLayer->findAllStarred($this->userId),
1500
			'albums' => $this->albumBusinessLayer->findAllStarred($this->userId),
1501
			'tracks' => $this->trackBusinessLayer->findAllStarred($this->userId)
1502
		];
1503
	}
1504
1505
	/**
1506
	 * Common response building logic for search2, search3, getStarred, and getStarred2
1507
	 * @param string $title Name of the main node in the response message
1508
	 * @param array $results Search results with keys 'artists', 'albums', and 'tracks'
1509
	 * @param boolean $useNewApi Set to true for search3 and getStarred2. There is a difference
1510
	 *                           in the formatting of the album nodes.
1511
	 * @return \OCP\AppFramework\Http\Response
1512
	 */
1513
	private function searchResponse($title, $results, $useNewApi) {
1514
		$albumMapFunc = $useNewApi ? 'albumToNewApi' : 'albumToOldApi';
1515
1516
		return $this->subsonicResponse([$title => [
1517
			'artist' => \array_map([$this, 'artistToApi'], $results['artists']),
1518
			'album' => \array_map([$this, $albumMapFunc], $results['albums']),
1519
			'song' => \array_map([$this, 'trackToApi'], $results['tracks'])
1520
		]]);
1521
	}
1522
1523
	/**
1524
	 * Find tracks by genre name
1525
	 * @param string $genreName
1526
	 * @param int|null $limit
1527
	 * @param int|null $offset
1528
	 * @return Track[]
1529
	 */
1530
	private function findTracksByGenre($genreName, $limit=null, $offset=null) {
1531
		$genre = $this->findGenreByName($genreName);
1532
1533
		if ($genre) {
1534
			return $this->trackBusinessLayer->findAllByGenre($genre->getId(), $this->userId, $limit, $offset);
1535
		} else {
1536
			return [];
1537
		}
1538
	}
1539
1540
	/**
1541
	 * Find albums by genre name
1542
	 * @param string $genreName
1543
	 * @param int|null $limit
1544
	 * @param int|null $offset
1545
	 * @return Album[]
1546
	 */
1547
	private function findAlbumsByGenre($genreName, $limit=null, $offset=null) {
1548
		$genre = $this->findGenreByName($genreName);
1549
1550
		if ($genre) {
1551
			return $this->albumBusinessLayer->findAllByGenre($genre->getId(), $this->userId, $limit, $offset);
1552
		} else {
1553
			return [];
1554
		}
1555
	}
1556
1557
	private function findGenreByName($name) {
1558
		$genreArr = $this->genreBusinessLayer->findAllByName($name, $this->userId);
1559
		if (\count($genreArr) == 0 && $name == Genre::unknownNameString($this->l10n)) {
1560
			$genreArr = $this->genreBusinessLayer->findAllByName('', $this->userId);
1561
		}
1562
		return \count($genreArr) ? $genreArr[0] : null;
1563
	}
1564
1565
	/**
1566
	 * Given a prefixed ID like 'artist-123' or 'track-45', return just the numeric part.
1567
	 */
1568
	private static function ripIdPrefix(string $id) : int {
1569
		return (int)(\explode('-', $id)[1]);
1570
	}
1571
1572
	private function subsonicResponse($content, $useAttributes=true, $status = 'ok') {
1573
		$content['status'] = $status;
1574
		$content['version'] = self::API_VERSION;
1575
		$responseData = ['subsonic-response' => $content];
1576
1577
		if ($this->format == 'json') {
1578
			$response = new JSONResponse($responseData);
1579
		} elseif ($this->format == 'jsonp') {
1580
			$responseData = \json_encode($responseData);
1581
			$response = new DataDisplayResponse("{$this->callback}($responseData);");
1582
			$response->addHeader('Content-Type', 'text/javascript; charset=UTF-8');
1583
		} else {
1584
			if (\is_array($useAttributes)) {
1585
				$useAttributes = \array_merge($useAttributes, ['status', 'version']);
1586
			}
1587
			$response = new XmlResponse($responseData, $useAttributes);
1588
		}
1589
1590
		return $response;
1591
	}
1592
1593
	public function subsonicErrorResponse($errorCode, $errorMessage) {
1594
		return $this->subsonicResponse([
1595
				'error' => [
1596
					'code' => $errorCode,
1597
					'message' => $errorMessage
1598
				]
1599
			], true, 'failed');
1600
	}
1601
}
1602