Passed
Push — master ( b806b4...5b46ab )
by Pauli
02:42
created

SubsonicController::trackToApi()   B

Complexity

Conditions 7
Paths 32

Size

Total Lines 35
Code Lines 28

Duplication

Lines 0
Ratio 0 %

Importance

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