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