Passed
Push — master ( 4a5f25...dc1961 )
by Pauli
02:27
created

SubsonicController::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 44
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

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

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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