Passed
Push — master ( 5b46ab...f9c9de )
by Pauli
09:30
created

SubsonicController::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 44
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

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

How to fix   Many Parameters   

Many Parameters

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

There are several approaches to avoid long parameter lists:

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