Passed
Push — master ( dc1961...4e74cd )
by Pauli
02:00
created

SubsonicController::ping()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
eloc 1
c 2
b 0
f 0
dl 0
loc 2
rs 10
cc 1
nc 1
nop 0
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