Passed
Push — master ( dc1961...4e74cd )
by Pauli
02:00
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() {
333
		// TODO: Not supported yet
334
		return $this->subsonicResponse(['topSongs' => []]);
335
	}
336
337
	/**
338
	 * @SubsonicAPI
339
	 */
340
	private function getAlbum(string $id) {
341
		$albumId = self::ripIdPrefix($id); // get rid of 'album-' prefix
342
343
		$album = $this->albumBusinessLayer->find($albumId, $this->userId);
344
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $this->userId);
345
346
		$albumNode = $this->albumToNewApi($album);
347
		$albumNode['song'] = \array_map(function ($track) use ($album) {
348
			$track->setAlbum($album);
349
			return $this->trackToApi($track);
350
		}, $tracks);
351
		return $this->subsonicResponse(['album' => $albumNode]);
352
	}
353
354
	/**
355
	 * @SubsonicAPI
356
	 */
357
	private function getSong(string $id) {
358
		$trackId = self::ripIdPrefix($id); // get rid of 'track-' prefix
359
		$track = $this->trackBusinessLayer->find($trackId, $this->userId);
360
361
		return $this->subsonicResponse(['song' => $this->trackToApi($track)]);
362
	}
363
364
	/**
365
	 * @SubsonicAPI
366
	 */
367
	private function getRandomSongs(?string $genre, ?string $fromYear, ?string $toYear, int $size=10) {
368
		$size = \min($size, 500); // the API spec limits the maximum amount to 500
369
370
		if ($genre !== null) {
371
			$trackPool = $this->findTracksByGenre($genre);
372
		} else {
373
			$trackPool = $this->trackBusinessLayer->findAll($this->userId);
374
		}
375
376
		if ($fromYear !== null) {
377
			$trackPool = \array_filter($trackPool, function ($track) use ($fromYear) {
378
				return ($track->getYear() !== null && $track->getYear() >= $fromYear);
379
			});
380
		}
381
382
		if ($toYear !== null) {
383
			$trackPool = \array_filter($trackPool, function ($track) use ($toYear) {
384
				return ($track->getYear() !== null && $track->getYear() <= $toYear);
385
			});
386
		}
387
388
		$tracks = Random::pickItems($trackPool, $size);
389
390
		return $this->subsonicResponse(['randomSongs' =>
391
				['song' => \array_map([$this, 'trackToApi'], $tracks)]
392
		]);
393
	}
394
395
	/**
396
	 * @SubsonicAPI
397
	 */
398
	private function getCoverArt(string $id, ?int $size) {
399
		$idParts = \explode('-', $id);
400
		$type = $idParts[0];
401
		$entityId = (int)($idParts[1]);
402
403
		if ($type == 'album') {
404
			$entity = $this->albumBusinessLayer->find($entityId, $this->userId);
405
		} elseif ($type == 'artist') {
406
			$entity = $this->artistBusinessLayer->find($entityId, $this->userId);
407
		} elseif ($type == 'podcast_channel') {
408
			$entity = $this->podcastService->getChannel($entityId, $this->userId, /*$includeEpisodes=*/ false);
409
		}
410
411
		if (!empty($entity)) {
412
			$rootFolder = $this->userMusicFolder->getFolder($this->userId);
413
			$coverData = $this->coverHelper->getCover($entity, $this->userId, $rootFolder, $size);
414
415
			if ($coverData !== null) {
416
				return new FileResponse($coverData);
417
			}
418
		}
419
420
		return $this->subsonicErrorResponse(70, "entity $id has no cover");
421
	}
422
423
	/**
424
	 * @SubsonicAPI
425
	 */
426
	private function getLyrics(?string $artist, ?string $title) {
427
		$matches = $this->trackBusinessLayer->findAllByNameAndArtistName($title, $artist, $this->userId);
428
		$matchingCount = \count($matches);
429
430
		if ($matchingCount === 0) {
431
			$this->logger->log("No matching track for title '$title' and artist '$artist'", 'debug');
432
			return $this->subsonicResponse(['lyrics' => new \stdClass]);
433
		} else {
434
			if ($matchingCount > 1) {
435
				$this->logger->log("Found $matchingCount tracks matching title ".
436
								"'$title' and artist '$artist'; using the first", 'debug');
437
			}
438
			$track = $matches[0];
439
440
			$artistObj = $this->artistBusinessLayer->find($track->getArtistId(), $this->userId);
441
			$rootFolder = $this->userMusicFolder->getFolder($this->userId);
442
			$lyrics = $this->detailsHelper->getLyrics($track->getFileId(), $rootFolder);
443
444
			return $this->subsonicResponse(['lyrics' => [
445
					'artist' => $artistObj->getNameString($this->l10n),
446
					'title' => $track->getTitle(),
447
					'value' => $lyrics
448
			]]);
449
		}
450
	}
451
452
	/**
453
	 * @SubsonicAPI
454
	 */
455
	private function stream(string $id) {
456
		// We don't support transcaoding, so 'stream' and 'download' act identically
457
		return $this->download($id);
458
	}
459
460
	/**
461
	 * @SubsonicAPI
462
	 */
463
	private function download(string $id) {
464
		$idParts = \explode('-', $id);
465
		$type = $idParts[0];
466
		$entityId = (int)($idParts[1]);
467
468
		if ($type === 'track') {
469
			$track = $this->trackBusinessLayer->find($entityId, $this->userId);
470
			$file = $this->getFilesystemNode($track->getFileId());
471
472
			if ($file instanceof File) {
473
				return new FileStreamResponse($file);
474
			} else {
475
				return $this->subsonicErrorResponse(70, 'file not found');
476
			}
477
		} elseif ($type === 'podcast_episode') {
478
			$episode = $this->podcastService->getEpisode($entityId, $this->userId);
479
			if ($episode instanceof PodcastEpisode) {
480
				return new RedirectResponse($episode->getStreamUrl());
481
			} else {
482
				return $this->subsonicErrorResponse(70, 'episode not found');
483
			}
484
		} else {
485
			return $this->subsonicErrorResponse(0, "id of type $type not supported");
486
		}
487
	}
488
489
	/**
490
	 * @SubsonicAPI
491
	 */
492
	private function search2(string $query, int $artistCount=20, int $artistOffset=0,
493
			int $albumCount=20, int $albumOffset=0, int $songCount=20, int $songOffset=0) {
494
		$results = $this->doSearch($query, $artistCount, $artistOffset, $albumCount, $albumOffset, $songCount, $songOffset);
495
		return $this->searchResponse('searchResult2', $results, /*$useNewApi=*/false);
496
	}
497
498
	/**
499
	 * @SubsonicAPI
500
	 */
501
	private function search3(string $query, int $artistCount=20, int $artistOffset=0,
502
			int $albumCount=20, int $albumOffset=0, int $songCount=20, int $songOffset=0) {
503
		$results = $this->doSearch($query, $artistCount, $artistOffset, $albumCount, $albumOffset, $songCount, $songOffset);
504
		return $this->searchResponse('searchResult3', $results, /*$useNewApi=*/true);
505
	}
506
507
	/**
508
	 * @SubsonicAPI
509
	 */
510
	private function getGenres() {
511
		$genres = $this->genreBusinessLayer->findAll($this->userId, SortBy::Name);
512
513
		return $this->subsonicResponse(['genres' =>
514
			[
515
				'genre' => \array_map(function ($genre) {
516
					return [
517
						'songCount' => $genre->getTrackCount(),
518
						'albumCount' => $genre->getAlbumCount(),
519
						'value' => $genre->getNameString($this->l10n)
520
					];
521
				},
522
				$genres)
523
			]
524
		]);
525
	}
526
527
	/**
528
	 * @SubsonicAPI
529
	 */
530
	private function getSongsByGenre(string $genre, int $count=10, int $offset=0) {
531
		$tracks = $this->findTracksByGenre($genre, $count, $offset);
532
533
		return $this->subsonicResponse(['songsByGenre' =>
534
			['song' => \array_map([$this, 'trackToApi'], $tracks)]
535
		]);
536
	}
537
538
	/**
539
	 * @SubsonicAPI
540
	 */
541
	private function getPlaylists() {
542
		$playlists = $this->playlistBusinessLayer->findAll($this->userId);
543
544
		return $this->subsonicResponse(['playlists' =>
545
			['playlist' => \array_map([$this, 'playlistToApi'], $playlists)]
546
		]);
547
	}
548
549
	/**
550
	 * @SubsonicAPI
551
	 */
552
	private function getPlaylist(int $id) {
553
		$playlist = $this->playlistBusinessLayer->find($id, $this->userId);
554
		$tracks = $this->playlistBusinessLayer->getPlaylistTracks($id, $this->userId);
555
556
		$playlistNode = $this->playlistToApi($playlist);
557
		$playlistNode['entry'] = \array_map([$this, 'trackToApi'], $tracks);
558
559
		return $this->subsonicResponse(['playlist' => $playlistNode]);
560
	}
561
562
	/**
563
	 * @SubsonicAPI
564
	 */
565
	private function createPlaylist(?string $name, ?string $playlistId, array $songId) {
566
		$songIds = \array_map('self::ripIdPrefix', $songId);
567
568
		// If playlist ID has been passed, then this method actually updates an existing list instead of creating a new one.
569
		// 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).
570
		if (!empty($playlistId)) {
571
			$playlist = $this->playlistBusinessLayer->find((int)$playlistId, $this->userId);
572
		} elseif (!empty($name)) {
573
			$playlist = $this->playlistBusinessLayer->create($name, $this->userId);
574
		} else {
575
			throw new SubsonicException('Playlist ID or name must be specified.', 10);
576
		}
577
578
		$playlist->setTrackIdsFromArray($songIds);
579
		$this->playlistBusinessLayer->update($playlist);
580
581
		return $this->subsonicResponse([]);
582
	}
583
584
	/**
585
	 * @SubsonicAPI
586
	 */
587
	private function updatePlaylist(int $playlistId, ?string $name, ?string $comment, array $songIdToAdd, array $songIndexToRemove) {
588
		$songIdsToAdd = \array_map('self::ripIdPrefix', $songIdToAdd);
589
		$songIndicesToRemove = \array_map('intval', $songIndexToRemove);
590
591
		if (!empty($name)) {
592
			$this->playlistBusinessLayer->rename($name, $playlistId, $this->userId);
593
		}
594
595
		if ($comment!== null) {
596
			$this->playlistBusinessLayer->setComment($comment, $playlistId, $this->userId);
597
		}
598
599
		if (!empty($songIndicesToRemove)) {
600
			$this->playlistBusinessLayer->removeTracks($songIndicesToRemove, $playlistId, $this->userId);
601
		}
602
603
		if (!empty($songIdsToAdd)) {
604
			$this->playlistBusinessLayer->addTracks($songIdsToAdd, $playlistId, $this->userId);
605
		}
606
607
		return $this->subsonicResponse([]);
608
	}
609
610
	/**
611
	 * @SubsonicAPI
612
	 */
613
	private function deletePlaylist(int $id) {
614
		$this->playlistBusinessLayer->delete($id, $this->userId);
615
		return $this->subsonicResponse([]);
616
	}
617
618
	/**
619
	 * @SubsonicAPI
620
	 */
621
	private function getInternetRadioStations() {
622
		$stations = $this->radioStationBusinessLayer->findAll($this->userId);
623
624
		return $this->subsonicResponse(['internetRadioStations' =>
625
				['internetRadioStation' => \array_map(function($station) {
626
					return [
627
						'id' => $station->getId(),
628
						'name' => $station->getName(),
629
						'streamUrl' => $station->getStreamUrl()
630
					];
631
				}, $stations)]
632
		]);
633
	}
634
635
	/**
636
	 * @SubsonicAPI
637
	 */
638
	private function getUser(string $username) {
639
		if ($username != $this->userId) {
640
			throw new SubsonicException("{$this->userId} is not authorized to get details for other users.", 50);
641
		}
642
643
		return $this->subsonicResponse([
644
			'user' => [
645
				'username' => $username,
646
				'email' => '',
647
				'scrobblingEnabled' => false,
648
				'adminRole' => false,
649
				'settingsRole' => false,
650
				'downloadRole' => true,
651
				'uploadRole' => false,
652
				'playlistRole' => true,
653
				'coverArtRole' => false,
654
				'commentRole' => false,
655
				'podcastRole' => true,
656
				'streamRole' => true,
657
				'jukeboxRole' => false,
658
				'shareRole' => false,
659
				'videoConversionRole' => false,
660
				'folder' => ['artists', 'folders'],
661
			]
662
		]);
663
	}
664
665
	/**
666
	 * @SubsonicAPI
667
	 */
668
	private function getUsers() {
669
		throw new SubsonicException("{$this->userId} is not authorized to get details for other users.", 50);
670
	}
671
672
	/**
673
	 * @SubsonicAPI
674
	 */
675
	private function getAvatar(string $username) {
676
		if ($username != $this->userId) {
677
			throw new SubsonicException("{$this->userId} is not authorized to get avatar for other users.", 50);
678
		}
679
680
		$image = $this->userManager->get($username)->getAvatarImage(150);
681
682
		if ($image !== null) {
683
			return new FileResponse(['content' => $image->data(), 'mimetype' => $image->mimeType()]);
684
		} else {
685
			return $this->subsonicErrorResponse(70, 'user has no avatar');
686
		}
687
	}
688
689
	/**
690
	 * @SubsonicAPI
691
	 */
692
	private function star(array $id, array $albumId, array $artistId) {
693
		$targetIds = self::parseStarringParameters($id, $albumId, $artistId);
694
695
		$this->trackBusinessLayer->setStarred($targetIds['tracks'], $this->userId);
696
		$this->albumBusinessLayer->setStarred($targetIds['albums'], $this->userId);
697
		$this->artistBusinessLayer->setStarred($targetIds['artists'], $this->userId);
698
		$this->podcastChannelBusinessLayer->setStarred($targetIds['podcast_channels'], $this->userId);
699
		$this->podcastEpisodeBusinessLayer->setStarred($targetIds['podcast_episodes'], $this->userId);
700
701
		return $this->subsonicResponse([]);
702
	}
703
704
	/**
705
	 * @SubsonicAPI
706
	 */
707
	private function unstar(array $id, array $albumId, array $artistId) {
708
		$targetIds = self::parseStarringParameters($id, $albumId, $artistId);
709
710
		$this->trackBusinessLayer->unsetStarred($targetIds['tracks'], $this->userId);
711
		$this->albumBusinessLayer->unsetStarred($targetIds['albums'], $this->userId);
712
		$this->artistBusinessLayer->unsetStarred($targetIds['artists'], $this->userId);
713
		$this->podcastChannelBusinessLayer->unsetStarred($targetIds['podcast_channels'], $this->userId);
714
		$this->podcastEpisodeBusinessLayer->unsetStarred($targetIds['podcast_episodes'], $this->userId);
715
716
		return $this->subsonicResponse([]);
717
	}
718
719
	/**
720
	 * @SubsonicAPI
721
	 */
722
	private function getStarred() {
723
		$starred = $this->doGetStarred();
724
		return $this->searchResponse('starred', $starred, /*$useNewApi=*/false);
725
	}
726
727
	/**
728
	 * @SubsonicAPI
729
	 */
730
	private function getStarred2() {
731
		$starred = $this->doGetStarred();
732
		return $this->searchResponse('starred2', $starred, /*$useNewApi=*/true);
733
	}
734
735
	/**
736
	 * @SubsonicAPI
737
	 */
738
	private function getVideos() {
739
		// Feature not supported, return an empty list
740
		return $this->subsonicResponse([
741
			'videos' => [
742
				'video' => []
743
			]
744
		]);
745
	}
746
747
	/**
748
	 * @SubsonicAPI
749
	 */
750
	private function getPodcasts(?string $id, bool $includeEpisodes = true) {
751
		if ($id !== null) {
752
			$id = self::ripIdPrefix($id);
753
			$channel = $this->podcastService->getChannel($id, $this->userId, $includeEpisodes);
754
			if ($channel === null) {
755
				throw new SubsonicException('Requested channel not found', 70);
756
			}
757
			$channels = [$channel];
758
		} else {
759
			$channels = $this->podcastService->getAllChannels($this->userId, $includeEpisodes);
760
		}
761
762
		return $this->subsonicResponse([
763
			'podcasts' => [
764
				'channel' => Util::arrayMapMethod($channels, 'toSubsonicApi')
765
			]
766
		]);
767
	}
768
769
	/**
770
	 * @SubsonicAPI
771
	 */
772
	private function getNewestPodcasts(int $count=20) {
773
		$episodes = $this->podcastService->getLatestEpisodes($this->userId, $count);
774
775
		return $this->subsonicResponse([
776
			'newestPodcasts' => [
777
				'episode' => Util::arrayMapMethod($episodes, 'toSubsonicApi')
778
			]
779
		]);
780
	}
781
782
	/**
783
	 * @SubsonicAPI
784
	 */
785
	private function refreshPodcasts() {
786
		$this->podcastService->updateAllChannels($this->userId);
787
		return $this->subsonicResponse([]);
788
	}
789
790
	/**
791
	 * @SubsonicAPI
792
	 */
793
	private function createPodcastChannel(string $url) {
794
		$result = $this->podcastService->subscribe($url, $this->userId);
795
796
		switch ($result['status']) {
797
			case PodcastService::STATUS_OK:
798
				return $this->subsonicResponse([]);
799
			case PodcastService::STATUS_INVALID_URL:
800
				throw new SubsonicException("Invalid URL $url", 0);
801
			case PodcastService::STATUS_INVALID_RSS:
802
				throw new SubsonicException("The document at URL $url is not a valid podcast RSS feed", 0);
803
			case PodcastService::STATUS_ALREADY_EXISTS:
804
				throw new SubsonicException('User already has this podcast channel subscribed', 0);
805
			default:
806
				throw new SubsonicException("Unexpected status code {$result['status']}", 0);
807
		}
808
	}
809
810
	/**
811
	 * @SubsonicAPI
812
	 */
813
	private function deletePodcastChannel(string $id) {
814
		$id = self::ripIdPrefix($id);
815
		$status = $this->podcastService->unsubscribe($id, $this->userId);
816
817
		switch ($status) {
818
			case PodcastService::STATUS_OK:
819
				return $this->subsonicResponse([]);
820
			case PodcastService::STATUS_NOT_FOUND:
821
				throw new SubsonicException('Channel to be deleted not found', 70);
822
			default:
823
				throw new SubsonicException("Unexpected status code $status", 0);
824
		}
825
	}
826
827
	/**
828
	 * @SubsonicAPI
829
	 */
830
	private function getBookmarks() {
831
		$bookmarkNodes = [];
832
		$bookmarks = $this->bookmarkBusinessLayer->findAll($this->userId);
833
834
		foreach ($bookmarks as $bookmark) {
835
			$node = $bookmark->toSubsonicApi();
836
			$entryId = $bookmark->getEntryId();
837
			$type = $bookmark->getType();
838
839
			try {
840
				if ($type === Bookmark::TYPE_TRACK) {
841
					$node['entry'] = $this->trackToApi($this->trackBusinessLayer->find($entryId, $this->userId));
842
				} elseif ($type === Bookmark::TYPE_PODCAST_EPISODE) {
843
					$node['entry'] = $this->podcastEpisodeBusinessLayer->find($entryId, $this->userId)->toSubsonicApi();
844
				} else {
845
					$this->logger->log("Bookmark {$bookmark->getId()} had unexpected entry type $type", 'warn');
846
				}
847
				$bookmarkNodes[] = $node;
848
			} catch (BusinessLayerException $e) {
849
				$this->logger->log("Bookmarked entry with type $type and id $entryId not found", 'warn');
850
			}
851
		}
852
853
		return $this->subsonicResponse(['bookmarks' => ['bookmark' => $bookmarkNodes]]);
854
	}
855
856
	/**
857
	 * @SubsonicAPI
858
	 */
859
	private function createBookmark(string $id, int $position, ?string $comment) {
860
		list($type, $entityId) = self::parseBookamrkIdParam($id);
861
		$this->bookmarkBusinessLayer->addOrUpdate($this->userId, $type, $entityId, $position, $comment);
862
		return $this->subsonicResponse([]);
863
	}
864
865
	/**
866
	 * @SubsonicAPI
867
	 */
868
	private function deleteBookmark(string $id) {
869
		list($type, $entityId) = self::parseBookamrkIdParam($id);
870
871
		$bookmark = $this->bookmarkBusinessLayer->findByEntry($type, $entityId, $this->userId);
872
		$this->bookmarkBusinessLayer->delete($bookmark->getId(), $this->userId);
873
874
		return $this->subsonicResponse([]);
875
	}
876
877
	/**
878
	 * @SubsonicAPI
879
	 */
880
	private function getPlayQueue() {
881
		// TODO: not supported yet
882
		return $this->subsonicResponse(['playQueue' => []]);
883
	}
884
885
	/**
886
	 * @SubsonicAPI
887
	 */
888
	private function savePlayQueue() {
889
		// TODO: not supported yet
890
		return $this->subsonicResponse([]);
891
	}
892
893
	/* -------------------------------------------------------------------------
894
	 * Helper methods
895
	 *------------------------------------------------------------------------*/
896
897
	private static function ensureParamHasValue(string $paramName, /*mixed*/ $paramValue) {
898
		if ($paramValue === null || $paramValue === '') {
899
			throw new SubsonicException("Required parameter '$paramName' missing", 10);
900
		}
901
	}
902
903
	private static function parseBookamrkIdParam(string $id) : array {
904
		list($typeName, $entityId) = \explode('-', $id);
905
906
		if ($typeName === 'track') {
907
			$type = Bookmark::TYPE_TRACK;
908
		} elseif ($typeName === 'podcast_episode') {
909
			$type = Bookmark::TYPE_PODCAST_EPISODE;
910
		} else {
911
			throw new SubsonicException("Unsupported ID format $id", 0);
912
		}
913
914
		return [$type, (int)$entityId];
915
	}
916
917
	/**
918
	 * Parse parameters used in the `star` and `unstar` API methods
919
	 */
920
	private static function parseStarringParameters(array $ids, array $albumIds, array $artistIds) {
921
		// album IDs from newer clients
922
		$albumIds = \array_map('self::ripIdPrefix', $albumIds);
923
924
		// artist IDs from newer clients
925
		$artistIds = \array_map('self::ripIdPrefix', $artistIds);
926
927
		// Song IDs from newer clients and song/folder/album/artist IDs from older clients are all packed in $ids.
928
		// Also podcast IDs may come there; that is not documented part of the API but at least DSub does that.
929
930
		$trackIds = [];
931
		$channelIds = [];
932
		$episodeIds = [];
933
934
		foreach ($ids as $prefixedId) {
935
			$parts = \explode('-', $prefixedId);
936
			$type = $parts[0];
937
			$id = (int)$parts[1];
938
939
			if ($type == 'track') {
940
				$trackIds[] = $id;
941
			} elseif ($type == 'album') {
942
				$albumIds[] = $id;
943
			} elseif ($type == 'artist') {
944
				$artistIds[] = $id;
945
			} elseif ($type == 'podcast_channel') {
946
				$channelIds[] = $id;
947
			} elseif ($type == 'podcast_episode') {
948
				$episodeIds[] = $id;
949
			} elseif ($type == 'folder') {
950
				throw new SubsonicException('Starring folders is not supported', 0);
951
			} else {
952
				throw new SubsonicException("Unexpected ID format: $prefixedId", 0);
953
			}
954
		}
955
956
		return [
957
			'tracks' => $trackIds,
958
			'albums' => $albumIds,
959
			'artists' => $artistIds,
960
			'podcast_channels' => $channelIds,
961
			'podcast_episodes' => $episodeIds
962
		];
963
	}
964
965
	private function getFilesystemNode($id) {
966
		$rootFolder = $this->userMusicFolder->getFolder($this->userId);
967
		$nodes = $rootFolder->getById($id);
968
969
		if (\count($nodes) != 1) {
970
			throw new SubsonicException('file not found', 70);
971
		}
972
973
		return $nodes[0];
974
	}
975
976
	private function getIndexesForFolders() {
977
		$rootFolder = $this->userMusicFolder->getFolder($this->userId);
978
979
		return $this->subsonicResponse(['indexes' => ['index' => [
980
			['name' => '*',
981
			'artist' => [['id' => 'folder-' . $rootFolder->getId(), 'name' => $rootFolder->getName()]]]
982
		]]]);
983
	}
984
985
	private function getMusicDirectoryForFolder($id) {
986
		$folderId = self::ripIdPrefix($id); // get rid of 'folder-' prefix
987
		$folder = $this->getFilesystemNode($folderId);
988
989
		if (!($folder instanceof Folder)) {
990
			throw new SubsonicException("$id is not a valid folder", 70);
991
		}
992
993
		$nodes = $folder->getDirectoryListing();
994
		$subFolders = \array_filter($nodes, function ($n) {
995
			return $n instanceof Folder;
996
		});
997
		$tracks = $this->trackBusinessLayer->findAllByFolder($folderId, $this->userId);
998
999
		// A folder may contain thousands of audio files, and getting album data
1000
		// for each of those individually would take a lot of time and great many DB queries.
1001
		// To prevent having to do this in `trackToApi`, we fetch all the albums in one go.
1002
		$this->albumBusinessLayer->injectAlbumsToTracks($tracks, $this->userId);
1003
1004
		$children = \array_merge(
1005
			\array_map([$this, 'folderToApi'], $subFolders),
1006
			\array_map([$this, 'trackToApi'], $tracks)
1007
		);
1008
1009
		$content = [
1010
			'directory' => [
1011
				'id' => $id,
1012
				'name' => $folder->getName(),
1013
				'child' => $children
1014
			]
1015
		];
1016
1017
		// Parent folder ID is included if and only if the parent folder is not the top level
1018
		$rootFolderId = $this->userMusicFolder->getFolder($this->userId)->getId();
1019
		$parentFolderId = $folder->getParent()->getId();
1020
		if ($rootFolderId != $parentFolderId) {
1021
			$content['parent'] = 'folder-' . $parentFolderId;
1022
		}
1023
1024
		return $this->subsonicResponse($content);
1025
	}
1026
1027
	private function getIndexesForArtists($rootElementName = 'indexes') {
1028
		$artists = $this->artistBusinessLayer->findAllHavingAlbums($this->userId, SortBy::Name);
1029
1030
		$indexes = [];
1031
		foreach ($artists as $artist) {
1032
			$indexes[$artist->getIndexingChar()][] = $this->artistToApi($artist);
1033
		}
1034
1035
		$result = [];
1036
		foreach ($indexes as $indexChar => $bucketArtists) {
1037
			$result[] = ['name' => $indexChar, 'artist' => $bucketArtists];
1038
		}
1039
1040
		return $this->subsonicResponse([$rootElementName => ['index' => $result]]);
1041
	}
1042
1043
	private function getMusicDirectoryForArtist($id) {
1044
		$artistId = self::ripIdPrefix($id); // get rid of 'artist-' prefix
1045
1046
		$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
1047
		$albums = $this->albumBusinessLayer->findAllByArtist($artistId, $this->userId);
1048
1049
		return $this->subsonicResponse([
1050
			'directory' => [
1051
				'id' => $id,
1052
				'name' => $artist->getNameString($this->l10n),
1053
				'child' => \array_map([$this, 'albumToOldApi'], $albums)
1054
			]
1055
		]);
1056
	}
1057
1058
	private function getMusicDirectoryForAlbum($id) {
1059
		$albumId = self::ripIdPrefix($id); // get rid of 'album-' prefix
1060
1061
		$album = $this->albumBusinessLayer->find($albumId, $this->userId);
1062
		$albumName = $album->getNameString($this->l10n);
1063
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $this->userId);
1064
1065
		return $this->subsonicResponse([
1066
			'directory' => [
1067
				'id' => $id,
1068
				'parent' => 'artist-' . $album->getAlbumArtistId(),
1069
				'name' => $albumName,
1070
				'child' => \array_map(function ($track) use ($album) {
1071
					$track->setAlbum($album);
1072
					return $this->trackToApi($track);
1073
				}, $tracks)
1074
			]
1075
		]);
1076
	}
1077
1078
	private function getMusicDirectoryForPodcastChannel($id) {
1079
		$channelId = self::ripIdPrefix($id); // get rid of 'podcast_channel-' prefix
1080
		$channel = $this->podcastService->getChannel($channelId, $this->userId, /*$includeEpisodes=*/ true);
1081
1082
		if ($channel === null) {
1083
			throw new SubsonicException("Podcast channel $channelId not found", 0);
1084
		}
1085
1086
		return $this->subsonicResponse([
1087
			'directory' => [
1088
				'id' => $id,
1089
				'name' => $channel->getTitle(),
1090
				'child' => Util::arrayMapMethod($channel->getEpisodes(), 'toSubsonicApi')
1091
			]
1092
		]);
1093
	}
1094
1095
	/**
1096
	 * @param Folder $folder
1097
	 * @return array
1098
	 */
1099
	private function folderToApi($folder) {
1100
		return [
1101
			'id' => 'folder-' . $folder->getId(),
1102
			'title' => $folder->getName(),
1103
			'isDir' => true
1104
		];
1105
	}
1106
1107
	/**
1108
	 * @param Artist $artist
1109
	 * @return array
1110
	 */
1111
	private function artistToApi($artist) {
1112
		$id = $artist->getId();
1113
		$result = [
1114
			'name' => $artist->getNameString($this->l10n),
1115
			'id' => $id ? ('artist-' . $id) : '-1', // getArtistInfo may show artists without ID
1116
			'albumCount' => $id ? $this->albumBusinessLayer->countByArtist($id) : 0
1117
		];
1118
1119
		if (!empty($artist->getCoverFileId())) {
1120
			$result['coverArt'] = $result['id'];
1121
		}
1122
1123
		if (!empty($artist->getStarred())) {
1124
			$result['starred'] = Util::formatZuluDateTime($artist->getStarred());
1125
		}
1126
1127
		return $result;
1128
	}
1129
1130
	/**
1131
	 * The "old API" format is used e.g. in getMusicDirectory and getAlbumList
1132
	 * @param Album $album
1133
	 * @return array
1134
	 */
1135
	private function albumToOldApi($album) {
1136
		$result = $this->albumCommonApiFields($album);
1137
1138
		$result['parent'] = 'artist-' . $album->getAlbumArtistId();
1139
		$result['title'] = $album->getNameString($this->l10n);
1140
		$result['isDir'] = true;
1141
1142
		return $result;
1143
	}
1144
1145
	/**
1146
	 * The "new API" format is used e.g. in getAlbum and getAlbumList2
1147
	 * @param Album $album
1148
	 * @return array
1149
	 */
1150
	private function albumToNewApi($album) {
1151
		$result = $this->albumCommonApiFields($album);
1152
1153
		$result['artistId'] = 'artist-' . $album->getAlbumArtistId();
1154
		$result['name'] = $album->getNameString($this->l10n);
1155
		$result['songCount'] = $this->trackBusinessLayer->countByAlbum($album->getId());
1156
		$result['duration'] = $this->trackBusinessLayer->totalDurationOfAlbum($album->getId());
1157
1158
		return $result;
1159
	}
1160
1161
	private function albumCommonApiFields($album) {
1162
		$result = [
1163
			'id' => 'album-' . $album->getId(),
1164
			'artist' => $album->getAlbumArtistNameString($this->l10n),
1165
			'created' => Util::formatZuluDateTime($album->getCreated())
1166
		];
1167
1168
		if (!empty($album->getCoverFileId())) {
1169
			$result['coverArt'] = 'album-' . $album->getId();
1170
		}
1171
1172
		if (!empty($album->getStarred())) {
1173
			$result['starred'] = Util::formatZuluDateTime($album->getStarred());
1174
		}
1175
1176
		if (!empty($album->getGenres())) {
1177
			$result['genre'] = \implode(', ', \array_map(function (Genre $genre) {
1178
				return $genre->getNameString($this->l10n);
1179
			}, $album->getGenres()));
1180
		}
1181
1182
		if (!empty($album->getYears())) {
1183
			$result['year'] = $album->yearToAPI();
1184
		}
1185
1186
		return $result;
1187
	}
1188
1189
	/**
1190
	 * The same API format is used both on "old" and "new" API methods. The "new" API adds some
1191
	 * new fields for the songs, but providing some extra fields shouldn't be a problem for the
1192
	 * older clients.
1193
	 * @param Track $track If the track entity has no album references set, then it is automatically
1194
	 *                     fetched from the AlbumBusinessLayer module.
1195
	 * @return array
1196
	 */
1197
	private function trackToApi($track) {
1198
		$albumId = $track->getAlbumId();
1199
1200
		$album = $track->getAlbum();
1201
		if ($album === null && $albumId !== null) {
1202
			$album = $this->albumBusinessLayer->findOrDefault($albumId, $this->userId);
1203
			$track->setAlbum($album);
1204
		}
1205
1206
		$result = [
1207
			'id' => 'track-' . $track->getId(),
1208
			'parent' => 'album-' . $albumId,
1209
			//'discNumber' => $track->getDisk(), // not supported on any of the tested clients => adjust track number instead
1210
			'title' => $track->getTitle() ?? '',
1211
			'artist' => $track->getArtistNameString($this->l10n),
1212
			'isDir' => false,
1213
			'album' => $track->getAlbumNameString($this->l10n),
1214
			'year' => $track->getYear(),
1215
			'size' => $track->getSize() ?? 0,
1216
			'contentType' => $track->getMimetype() ?? '',
1217
			'suffix' => $track->getFileExtension(),
1218
			'duration' => $track->getLength() ?: 0,
1219
			'bitRate' => empty($track->getBitrate()) ? 0 : (int)\round($track->getBitrate()/1000), // convert bps to kbps
1220
			//'path' => '',
1221
			'isVideo' => false,
1222
			'albumId' => 'album-' . $albumId,
1223
			'artistId' => 'artist-' . $track->getArtistId(),
1224
			'type' => 'music',
1225
			'created' => Util::formatZuluDateTime($track->getCreated())
1226
		];
1227
1228
		if ($album !== null && !empty($album->getCoverFileId())) {
1229
			$result['coverArt'] = 'album-' . $album->getId();
1230
		}
1231
1232
		$trackNumber = $track->getAdjustedTrackNumber();
1233
		if ($trackNumber !== null) {
1234
			$result['track'] = $trackNumber;
1235
		}
1236
1237
		if (!empty($track->getStarred())) {
1238
			$result['starred'] = Util::formatZuluDateTime($track->getStarred());
1239
		}
1240
1241
		if (!empty($track->getGenreId())) {
1242
			$result['genre'] = $track->getGenreNameString($this->l10n);
1243
		}
1244
1245
		return $result;
1246
	}
1247
1248
	/**
1249
	 * @param Playlist $playlist
1250
	 * @return array
1251
	 */
1252
	private function playlistToApi($playlist) {
1253
		return [
1254
			'id' => $playlist->getId(),
1255
			'name' => $playlist->getName(),
1256
			'owner' => $this->userId,
1257
			'public' => false,
1258
			'songCount' => $playlist->getTrackCount(),
1259
			'duration' => $this->playlistBusinessLayer->getDuration($playlist->getId(), $this->userId),
1260
			'comment' => $playlist->getComment() ?: '',
1261
			'created' => Util::formatZuluDateTime($playlist->getCreated()),
1262
			'changed' => Util::formatZuluDateTime($playlist->getUpdated())
1263
			//'coverArt' => '' // added in API 1.11.0 but is optional even there
1264
		];
1265
	}
1266
1267
	/**
1268
	 * Common logic for getAlbumList and getAlbumList2
1269
	 * @return Album[]
1270
	 */
1271
	private function albumsForGetAlbumList(
1272
			string $type, ?string $genre, ?int $fromYear, ?int $toYear, int $size, int $offset) : array {
1273
		$size = \min($size, 500); // the API spec limits the maximum amount to 500
1274
1275
		$albums = [];
1276
1277
		switch ($type) {
1278
			case 'random':
1279
				$allAlbums = $this->albumBusinessLayer->findAll($this->userId);
1280
				$indices = $this->random->getIndices(\count($allAlbums), $offset, $size, $this->userId, 'subsonic_albums');
1281
				$albums = Util::arrayMultiGet($allAlbums, $indices);
1282
				break;
1283
			case 'starred':
1284
				$albums = $this->albumBusinessLayer->findAllStarred($this->userId, $size, $offset);
1285
				break;
1286
			case 'alphabeticalByName':
1287
				$albums = $this->albumBusinessLayer->findAll($this->userId, SortBy::Name, $size, $offset);
1288
				break;
1289
			case 'alphabeticalByArtist':
1290
				$albums = $this->albumBusinessLayer->findAll($this->userId, SortBy::Parent, $size, $offset);
1291
				break;
1292
			case 'byGenre':
1293
				self::ensureParamHasValue('genre', $genre);
1294
				$albums = $this->findAlbumsByGenre($genre, $size, $offset);
1295
				break;
1296
			case 'byYear':
1297
				self::ensureParamHasValue('fromYear', $fromYear);
1298
				self::ensureParamHasValue('toYear', $toYear);
1299
				$albums = $this->albumBusinessLayer->findAllByYearRange($fromYear, $toYear, $this->userId, $size, $offset);
1300
				break;
1301
			case 'newest':
1302
				$albums = $this->albumBusinessLayer->findAll($this->userId, SortBy::Newest, $size, $offset);
1303
				break;
1304
			case 'highest':
1305
			case 'frequent':
1306
			case 'recent':
1307
			default:
1308
				$this->logger->log("Album list type '$type' is not supported", 'debug');
1309
				break;
1310
		}
1311
1312
		return $albums;
1313
	}
1314
1315
	/**
1316
	 * Common logic for getArtistInfo and getArtistInfo2
1317
	 */
1318
	private function doGetArtistInfo(string $rootName, string $id, bool $includeNotPresent) {
1319
		$content = [];
1320
1321
		// This function may be called with a folder ID instead of an artist ID in case
1322
		// the library is being browsed by folders. In that case, return an empty response.
1323
		if (Util::startsWith($id, 'artist')) {
1324
			$artistId = self::ripIdPrefix($id); // get rid of 'artist-' prefix
1325
1326
			$info = $this->lastfmService->getArtistInfo($artistId, $this->userId);
1327
1328
			if (isset($info['artist'])) {
1329
				$content = [
1330
					'biography' => $info['artist']['bio']['summary'],
1331
					'lastFmUrl' => $info['artist']['url'],
1332
					'musicBrainzId' => $info['artist']['mbid'] ?? null
1333
				];
1334
1335
				$similarArtists = $this->lastfmService->getSimilarArtists($artistId, $this->userId, $includeNotPresent);
1336
				$content['similarArtist'] = \array_map([$this, 'artistToApi'], $similarArtists);
1337
			}
1338
1339
			$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
1340
			if ($artist->getCoverFileId() !== null) {
1341
				$par = $this->request->getParams();
1342
				$url = $this->urlGenerator->linkToRouteAbsolute('music.subsonic.handleRequest', ['method' => 'getCoverArt'])
1343
						. "?u={$par['u']}&p={$par['p']}&v={$par['v']}&c={$par['c']}&id=$id";
1344
				$content['largeImageUrl'] = [$url];
1345
			}
1346
		}
1347
1348
		// This method is unusual in how it uses non-attribute elements in the response. On the other hand,
1349
		// all the details of the <similarArtist> elements are rendered as attributes. List those separately.
1350
		$attributeKeys = ['name', 'id', 'albumCount', 'coverArt', 'starred'];
1351
1352
		return $this->subsonicResponse([$rootName => $content], $attributeKeys);
1353
	}
1354
1355
	/**
1356
	 * Common logic for getSimilarSongs and getSimilarSongs2
1357
	 */
1358
	private function doGetSimilarSongs(string $rootName, string $id, int $count) {
1359
		if (Util::startsWith($id, 'artist')) {
1360
			$artistId = self::ripIdPrefix($id);
1361
		} elseif (Util::startsWith($id, 'album')) {
1362
			$albumId = self::ripIdPrefix($id);
1363
			$artistId = $this->albumBusinessLayer->find($albumId, $this->userId)->getAlbumArtistId();
1364
		} elseif (Util::startsWith($id, 'track')) {
1365
			$trackId = self::ripIdPrefix($id);
1366
			$artistId = $this->trackBusinessLayer->find($trackId, $this->userId)->getArtistId();
1367
		} else {
1368
			throw new SubsonicException("Id $id has a type not supported on getSimilarSongs", 0);
1369
		}
1370
1371
		$artists = $this->lastfmService->getSimilarArtists($artistId, $this->userId);
1372
		$artists[] = $this->artistBusinessLayer->find($artistId, $this->userId);
1373
1374
		// Get all songs by the found artists
1375
		$songs = [];
1376
		foreach ($artists as $artist) {
1377
			$songs = \array_merge($songs, $this->trackBusinessLayer->findAllByArtist($artist->getId(), $this->userId));
1378
		}
1379
1380
		// Randomly select the desired number of songs
1381
		$songs = $this->random->pickItems($songs, $count);
1382
1383
		return $this->subsonicResponse([$rootName => [
1384
				'song' => \array_map([$this, 'trackToApi'], $songs)
1385
		]]);
1386
	}
1387
1388
	/**
1389
	 * Common logic for search2 and search3
1390
	 * @return array with keys 'artists', 'albums', and 'tracks'
1391
	 */
1392
	private function doSearch(string $query, int $artistCount, int $artistOffset,
1393
			int $albumCount, int $albumOffset, int $songCount, int $songOffset) {
1394
1395
		if (empty($query)) {
1396
			throw new SubsonicException("The 'query' argument is mandatory", 10);
1397
		}
1398
1399
		return [
1400
			'artists' => $this->artistBusinessLayer->findAllByName($query, $this->userId, true, $artistCount, $artistOffset),
1401
			'albums' => $this->albumBusinessLayer->findAllByName($query, $this->userId, true, $albumCount, $albumOffset),
1402
			'tracks' => $this->trackBusinessLayer->findAllByName($query, $this->userId, true, $songCount, $songOffset)
1403
		];
1404
	}
1405
1406
	/**
1407
	 * Common logic for getStarred and getStarred2
1408
	 * @return array
1409
	 */
1410
	private function doGetStarred() {
1411
		return [
1412
			'artists' => $this->artistBusinessLayer->findAllStarred($this->userId),
1413
			'albums' => $this->albumBusinessLayer->findAllStarred($this->userId),
1414
			'tracks' => $this->trackBusinessLayer->findAllStarred($this->userId)
1415
		];
1416
	}
1417
1418
	/**
1419
	 * Common response building logic for search2, search3, getStarred, and getStarred2
1420
	 * @param string $title Name of the main node in the response message
1421
	 * @param array $results Search results with keys 'artists', 'albums', and 'tracks'
1422
	 * @param boolean $useNewApi Set to true for search3 and getStarred2. There is a difference
1423
	 *                           in the formatting of the album nodes.
1424
	 * @return \OCP\AppFramework\Http\Response
1425
	 */
1426
	private function searchResponse($title, $results, $useNewApi) {
1427
		$albumMapFunc = $useNewApi ? 'albumToNewApi' : 'albumToOldApi';
1428
1429
		return $this->subsonicResponse([$title => [
1430
			'artist' => \array_map([$this, 'artistToApi'], $results['artists']),
1431
			'album' => \array_map([$this, $albumMapFunc], $results['albums']),
1432
			'song' => \array_map([$this, 'trackToApi'], $results['tracks'])
1433
		]]);
1434
	}
1435
1436
	/**
1437
	 * Find tracks by genre name
1438
	 * @param string $genreName
1439
	 * @param int|null $limit
1440
	 * @param int|null $offset
1441
	 * @return Track[]
1442
	 */
1443
	private function findTracksByGenre($genreName, $limit=null, $offset=null) {
1444
		$genre = $this->findGenreByName($genreName);
1445
1446
		if ($genre) {
1447
			return $this->trackBusinessLayer->findAllByGenre($genre->getId(), $this->userId, $limit, $offset);
1448
		} else {
1449
			return [];
1450
		}
1451
	}
1452
1453
	/**
1454
	 * Find albums by genre name
1455
	 * @param string $genreName
1456
	 * @param int|null $limit
1457
	 * @param int|null $offset
1458
	 * @return Album[]
1459
	 */
1460
	private function findAlbumsByGenre($genreName, $limit=null, $offset=null) {
1461
		$genre = $this->findGenreByName($genreName);
1462
1463
		if ($genre) {
1464
			return $this->albumBusinessLayer->findAllByGenre($genre->getId(), $this->userId, $limit, $offset);
1465
		} else {
1466
			return [];
1467
		}
1468
	}
1469
1470
	private function findGenreByName($name) {
1471
		$genreArr = $this->genreBusinessLayer->findAllByName($name, $this->userId);
1472
		if (\count($genreArr) == 0 && $name == Genre::unknownNameString($this->l10n)) {
1473
			$genreArr = $this->genreBusinessLayer->findAllByName('', $this->userId);
1474
		}
1475
		return \count($genreArr) ? $genreArr[0] : null;
1476
	}
1477
1478
	/**
1479
	 * Given a prefixed ID like 'artist-123' or 'track-45', return just the numeric part.
1480
	 */
1481
	private static function ripIdPrefix(string $id) : int {
1482
		return (int)(\explode('-', $id)[1]);
1483
	}
1484
1485
	private function subsonicResponse($content, $useAttributes=true, $status = 'ok') {
1486
		$content['status'] = $status;
1487
		$content['version'] = self::API_VERSION;
1488
		$responseData = ['subsonic-response' => $content];
1489
1490
		if ($this->format == 'json') {
1491
			$response = new JSONResponse($responseData);
1492
		} elseif ($this->format == 'jsonp') {
1493
			$responseData = \json_encode($responseData);
1494
			$response = new DataDisplayResponse("{$this->callback}($responseData);");
1495
			$response->addHeader('Content-Type', 'text/javascript; charset=UTF-8');
1496
		} else {
1497
			if (\is_array($useAttributes)) {
1498
				$useAttributes = \array_merge($useAttributes, ['status', 'version']);
1499
			}
1500
			$response = new XmlResponse($responseData, $useAttributes);
1501
		}
1502
1503
		return $response;
1504
	}
1505
1506
	public function subsonicErrorResponse($errorCode, $errorMessage) {
1507
		return $this->subsonicResponse([
1508
				'error' => [
1509
					'code' => $errorCode,
1510
					'message' => $errorMessage
1511
				]
1512
			], true, 'failed');
1513
	}
1514
}
1515