Passed
Pull Request — master (#1241)
by Matthew
04:39
created

SubsonicController::getPlayQueue()   B

Complexity

Conditions 6
Paths 2

Size

Total Lines 34
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 8
Bugs 0 Features 0
Metric Value
cc 6
eloc 20
nc 2
nop 0
dl 0
loc 34
rs 8.9777
c 8
b 0
f 0
1
<?php declare(strict_types=1);
2
3
/**
4
 * ownCloud - Music app
5
 *
6
 * This file is licensed under the Affero General Public License version 3 or
7
 * later. See the COPYING file.
8
 *
9
 * @author Pauli Järvinen <[email protected]>
10
 * @copyright Pauli Järvinen 2019 - 2025
11
 */
12
13
namespace OCA\Music\Controller;
14
15
use OCP\AppFramework\ApiController;
16
use OCP\AppFramework\Http\DataDisplayResponse;
17
use OCP\AppFramework\Http\JSONResponse;
18
use OCP\AppFramework\Http\RedirectResponse;
19
use OCP\AppFramework\Http\Response;
20
use OCP\Files\File;
21
use OCP\Files\Folder;
22
use OCP\Files\Node;
23
use OCP\IConfig;
24
use OCP\IL10N;
25
use OCP\IRequest;
26
use OCP\IUserManager;
27
use OCP\IURLGenerator;
28
29
use OCA\Music\AppFramework\BusinessLayer\BusinessLayerException;
30
use OCA\Music\AppFramework\Core\Logger;
31
use OCA\Music\AppFramework\Utility\MethodAnnotationReader;
32
use OCA\Music\AppFramework\Utility\RequestParameterExtractor;
33
use OCA\Music\AppFramework\Utility\RequestParameterExtractorException;
34
35
use OCA\Music\BusinessLayer\AlbumBusinessLayer;
36
use OCA\Music\BusinessLayer\ArtistBusinessLayer;
37
use OCA\Music\BusinessLayer\BookmarkBusinessLayer;
38
use OCA\Music\BusinessLayer\GenreBusinessLayer;
39
use OCA\Music\BusinessLayer\PlaylistBusinessLayer;
40
use OCA\Music\BusinessLayer\PodcastChannelBusinessLayer;
41
use OCA\Music\BusinessLayer\PodcastEpisodeBusinessLayer;
42
use OCA\Music\BusinessLayer\RadioStationBusinessLayer;
43
use OCA\Music\BusinessLayer\TrackBusinessLayer;
44
45
use OCA\Music\Db\Album;
46
use OCA\Music\Db\Artist;
47
use OCA\Music\Db\Bookmark;
48
use OCA\Music\Db\Genre;
49
use OCA\Music\Db\MatchMode;
50
use OCA\Music\Db\PodcastEpisode;
51
use OCA\Music\Db\SortBy;
52
use OCA\Music\Db\Track;
53
54
use OCA\Music\Http\FileResponse;
55
use OCA\Music\Http\FileStreamResponse;
56
use OCA\Music\Http\XmlResponse;
57
58
use OCA\Music\Middleware\SubsonicException;
59
60
use OCA\Music\Service\AmpacheImageService;
61
use OCA\Music\Service\CoverService;
62
use OCA\Music\Service\DetailsService;
63
use OCA\Music\Service\LastfmService;
64
use OCA\Music\Service\LibrarySettings;
65
use OCA\Music\Service\PodcastService;
66
67
use OCA\Music\Utility\AppInfo;
68
use OCA\Music\Utility\ArrayUtil;
69
use OCA\Music\Utility\HttpUtil;
70
use OCA\Music\Utility\Random;
71
use OCA\Music\Utility\StringUtil;
72
use OCA\Music\Utility\Util;
73
74
class SubsonicController extends ApiController {
75
	const API_VERSION = '1.16.1';
76
	const FOLDER_ID_ARTISTS = -1;
77
	const FOLDER_ID_FOLDERS = -2;
78
79
	private AlbumBusinessLayer $albumBusinessLayer;
80
	private ArtistBusinessLayer $artistBusinessLayer;
81
	private BookmarkBusinessLayer $bookmarkBusinessLayer;
82
	private GenreBusinessLayer $genreBusinessLayer;
83
	private PlaylistBusinessLayer $playlistBusinessLayer;
84
	private PodcastChannelBusinessLayer $podcastChannelBusinessLayer;
85
	private PodcastEpisodeBusinessLayer $podcastEpisodeBusinessLayer;
86
	private RadioStationBusinessLayer $radioStationBusinessLayer;
87
	private TrackBusinessLayer $trackBusinessLayer;
88
	private IURLGenerator $urlGenerator;
89
	private IUserManager $userManager;
90
	private LibrarySettings $librarySettings;
91
	private IL10N $l10n;
92
	private CoverService $coverService;
93
	private DetailsService $detailsService;
94
	private LastfmService $lastfmService;
95
	private PodcastService $podcastService;
96
	private AmpacheImageService $imageService;
97
	private Random $random;
98
	private Logger $logger;
99
	private IConfig $configManager;
100
	private ?string $userId;
101
	private ?int $keyId;
102
	private array $ignoredArticles;
103
	private string $format;
104
	private ?string $callback;
105
106
	public function __construct(string $appName,
107
								IRequest $request,
108
								IL10N $l10n,
109
								IURLGenerator $urlGenerator,
110
								IUserManager $userManager,
111
								AlbumBusinessLayer $albumBusinessLayer,
112
								ArtistBusinessLayer $artistBusinessLayer,
113
								BookmarkBusinessLayer $bookmarkBusinessLayer,
114
								GenreBusinessLayer $genreBusinessLayer,
115
								PlaylistBusinessLayer $playlistBusinessLayer,
116
								PodcastChannelBusinessLayer $podcastChannelBusinessLayer,
117
								PodcastEpisodeBusinessLayer $podcastEpisodeBusinessLayer,
118
								RadioStationBusinessLayer $radioStationBusinessLayer,
119
								TrackBusinessLayer $trackBusinessLayer,
120
								LibrarySettings $librarySettings,
121
								CoverService $coverService,
122
								DetailsService $detailsService,
123
								LastfmService $lastfmService,
124
								PodcastService $podcastService,
125
								AmpacheImageService $imageService,
126
								Random $random,
127
								Logger $logger,
128
								\OCP\IConfig $configManager) {
129
		parent::__construct($appName, $request, 'POST, GET', 'Authorization, Content-Type, Accept, X-Requested-With');
130
131
		$this->albumBusinessLayer = $albumBusinessLayer;
132
		$this->artistBusinessLayer = $artistBusinessLayer;
133
		$this->bookmarkBusinessLayer = $bookmarkBusinessLayer;
134
		$this->genreBusinessLayer = $genreBusinessLayer;
135
		$this->playlistBusinessLayer = $playlistBusinessLayer;
136
		$this->podcastChannelBusinessLayer = $podcastChannelBusinessLayer;
137
		$this->podcastEpisodeBusinessLayer = $podcastEpisodeBusinessLayer;
138
		$this->radioStationBusinessLayer = $radioStationBusinessLayer;
139
		$this->trackBusinessLayer = $trackBusinessLayer;
140
		$this->urlGenerator = $urlGenerator;
141
		$this->userManager = $userManager;
142
		$this->l10n = $l10n;
143
		$this->librarySettings = $librarySettings;
144
		$this->coverService = $coverService;
145
		$this->detailsService = $detailsService;
146
		$this->lastfmService = $lastfmService;
147
		$this->podcastService = $podcastService;
148
		$this->imageService = $imageService;
149
		$this->random = $random;
150
		$this->logger = $logger;
151
		$this->configManager = $configManager;
152
		$this->userId = null;
153
		$this->keyId = null;
154
		$this->ignoredArticles = [];
155
		$this->format = 'xml'; // default, should be immediately overridden by SubsonicMiddleware
156
	}
157
158
	/**
159
	 * Called by the middleware to set the response format to be used
160
	 * @param string $format Response format: xml/json/jsonp
161
	 * @param string|null $callback Function name to use if the @a $format is 'jsonp'
162
	 */
163
	public function setResponseFormat(string $format, ?string $callback = null) : void {
164
		$this->format = $format;
165
		$this->callback = $callback;
166
	}
167
168
	/**
169
	 * Called by the middleware once the user credentials have been checked
170
	 */
171
	public function setAuthenticatedUser(string $userId, int $keyId) : void {
172
		$this->userId = $userId;
173
		$this->keyId = $keyId;
174
		$this->ignoredArticles = $this->librarySettings->getIgnoredArticles($userId);
175
	}
176
177
	/**
178
	 * @NoAdminRequired
179
	 * @PublicPage
180
	 * @NoCSRFRequired
181
	 * @NoSameSiteCookieRequired
182
	 * @CORS
183
	 */
184
	public function handleRequest(string $method) : Response {
185
		$this->logger->debug("Subsonic request $method");
186
187
		// Allow calling all methods with or without the postfix ".view"
188
		if (StringUtil::endsWith($method, ".view")) {
189
			$method = \substr($method, 0, -\strlen(".view"));
190
		}
191
192
		// There's only one method allowed without a logged-in user
193
		if ($method !== 'getOpenSubsonicExtensions' && $this->userId === null) {
194
			throw new SubsonicException('User authentication required', 10);
195
		}
196
197
		// Allow calling any functions annotated to be part of the API
198
		if (\method_exists($this, $method)) {
199
			$annotationReader = new MethodAnnotationReader($this, $method);
200
			if ($annotationReader->hasAnnotation('SubsonicAPI')) {
201
				$parameterExtractor = new RequestParameterExtractor($this->request);
202
				try {
203
					$parameterValues = $parameterExtractor->getParametersForMethod($this, $method);
204
				} catch (RequestParameterExtractorException $ex) {
205
					return $this->subsonicErrorResponse(10, $ex->getMessage());
206
				}
207
				$response = \call_user_func_array([$this, $method], $parameterValues);
208
				// The API methods may return either a Response object or an array, which should be converted to Response
209
				if (!($response instanceof Response)) {
210
					$response = $this->subsonicResponse($response);
211
				}
212
				return $response;
213
			}
214
		}
215
216
		$this->logger->warning("Request $method not supported");
217
		return $this->subsonicErrorResponse(0, "Requested action $method is not supported");
218
	}
219
220
	/* -------------------------------------------------------------------------
221
	 * REST API methods
222
	 * -------------------------------------------------------------------------
223
	 */
224
225
	/**
226
	 * @SubsonicAPI
227
	 */
228
	protected function ping() : array {
229
		return [];
230
	}
231
232
	/**
233
	 * @SubsonicAPI
234
	 */
235
	protected function getLicense() : array {
236
		return [
237
			'license' => [
238
				'valid' => true
239
			]
240
		];
241
	}
242
243
	/**
244
	 * @SubsonicAPI
245
	 */
246
	protected function getMusicFolders() : array {
247
		// Only single root folder is supported
248
		return [
249
			'musicFolders' => ['musicFolder' => [
250
				['id' => self::FOLDER_ID_ARTISTS, 'name' => $this->l10n->t('Artists')],
251
				['id' => self::FOLDER_ID_FOLDERS, 'name' => $this->l10n->t('Folders')]
252
			]]
253
		];
254
	}
255
256
	/**
257
	 * @SubsonicAPI
258
	 */
259
	protected function getIndexes(?int $musicFolderId) : array {
260
		if ($musicFolderId === self::FOLDER_ID_FOLDERS) {
261
			return $this->getIndexesForFolders();
262
		} else {
263
			return $this->getIndexesForArtists();
264
		}
265
	}
266
267
	/**
268
	 * @SubsonicAPI
269
	 */
270
	protected function getMusicDirectory(string $id) : array {
271
		if (StringUtil::startsWith($id, 'folder-')) {
272
			return $this->getMusicDirectoryForFolder($id);
273
		} elseif (StringUtil::startsWith($id, 'artist-')) {
274
			return $this->getMusicDirectoryForArtist($id);
275
		} elseif (StringUtil::startsWith($id, 'album-')) {
276
			return $this->getMusicDirectoryForAlbum($id);
277
		} elseif (StringUtil::startsWith($id, 'podcast_channel-')) {
278
			return $this->getMusicDirectoryForPodcastChannel($id);
279
		} else {
280
			throw new SubsonicException("Unsupported id format $id");
281
		}
282
	}
283
284
	/**
285
	 * @SubsonicAPI
286
	 */
287
	protected function getAlbumList(
288
			string $type, ?string $genre, ?int $fromYear, ?int $toYear, int $size=10, int $offset=0) : array {
289
		$albums = $this->albumsForGetAlbumList($type, $genre, $fromYear, $toYear, $size, $offset);
290
		return ['albumList' => [
291
			'album' => \array_map([$this, 'albumToOldApi'], $albums)
292
		]];
293
	}
294
295
	/**
296
	 * @SubsonicAPI
297
	 */
298
	protected function getAlbumList2(
299
			string $type, ?string $genre, ?int $fromYear, ?int $toYear, int $size=10, int $offset=0) : array {
300
		/*
301
		 * According to the API specification, the difference between this and getAlbumList
302
		 * should be that this function would organize albums according the metadata while
303
		 * getAlbumList would organize them by folders. However, we organize by metadata
304
		 * also in getAlbumList, because that's more natural for the Music app and many/most
305
		 * clients do not support getAlbumList2.
306
		 */
307
		$albums = $this->albumsForGetAlbumList($type, $genre, $fromYear, $toYear, $size, $offset);
308
		return ['albumList2' => [
309
			'album' => \array_map([$this, 'albumToNewApi'], $albums)
310
		]];
311
	}
312
313
	/**
314
	 * @SubsonicAPI
315
	 */
316
	protected function getArtists() : array {
317
		return $this->getIndexesForArtists('artists');
318
	}
319
320
	/**
321
	 * @SubsonicAPI
322
	 */
323
	protected function getArtist(string $id) : array {
324
		$artistId = self::ripIdPrefix($id); // get rid of 'artist-' prefix
325
326
		$artist = $this->artistBusinessLayer->find($artistId, $this->user());
327
		$albums = $this->albumBusinessLayer->findAllByArtist($artistId, $this->user());
328
329
		$artistNode = $this->artistToApi($artist);
330
		$artistNode['album'] = \array_map([$this, 'albumToNewApi'], $albums);
331
332
		return ['artist' => $artistNode];
333
	}
334
335
	/**
336
	 * @SubsonicAPI
337
	 */
338
	protected function getArtistInfo(string $id, bool $includeNotPresent=false) : Response {
339
		return $this->doGetArtistInfo('artistInfo', $id, $includeNotPresent);
340
	}
341
342
	/**
343
	 * @SubsonicAPI
344
	 */
345
	protected function getArtistInfo2(string $id, bool $includeNotPresent=false) : Response {
346
		return $this->doGetArtistInfo('artistInfo2', $id, $includeNotPresent);
347
	}
348
349
	/**
350
	 * @SubsonicAPI
351
	 */
352
	protected function getAlbumInfo(string $id) : Response {
353
		return $this->doGetAlbumInfo($id);
354
	}
355
356
	/**
357
	 * @SubsonicAPI
358
	 */
359
	protected function getAlbumInfo2(string $id) : Response {
360
		return $this->doGetAlbumInfo($id);
361
	}
362
363
	/**
364
	 * @SubsonicAPI
365
	 */
366
	protected function getSimilarSongs(string $id, int $count=50) : array {
367
		return $this->doGetSimilarSongs('similarSongs', $id, $count);
368
	}
369
370
	/**
371
	 * @SubsonicAPI
372
	 */
373
	protected function getSimilarSongs2(string $id, int $count=50) : array {
374
		return $this->doGetSimilarSongs('similarSongs2', $id, $count);
375
	}
376
377
	/**
378
	 * @SubsonicAPI
379
	 */
380
	protected function getTopSongs(string $artist, int $count=50) : array {
381
		$tracks = $this->lastfmService->getTopTracks($artist, $this->user(), $count);
382
		return ['topSongs' => [
383
			'song' => $this->tracksToApi($tracks)
384
		]];
385
	}
386
387
	/**
388
	 * @SubsonicAPI
389
	 */
390
	protected function getAlbum(string $id) : array {
391
		$albumId = self::ripIdPrefix($id); // get rid of 'album-' prefix
392
393
		$album = $this->albumBusinessLayer->find($albumId, $this->user());
394
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $this->user());
395
396
		$albumNode = $this->albumToNewApi($album);
397
		$albumNode['song'] = $this->tracksToApi($tracks);
398
		return ['album' => $albumNode];
399
	}
400
401
	/**
402
	 * @SubsonicAPI
403
	 */
404
	protected function getSong(string $id) : array {
405
		$trackId = self::ripIdPrefix($id); // get rid of 'track-' prefix
406
		$track = $this->trackBusinessLayer->find($trackId, $this->user());
407
		return ['song' => $this->trackToApi($track)];
408
	}
409
410
	/**
411
	 * @SubsonicAPI
412
	 */
413
	protected function getRandomSongs(?string $genre, ?string $fromYear, ?string $toYear, int $size=10) : array {
414
		$size = \min($size, 500); // the API spec limits the maximum amount to 500
415
416
		if ($genre !== null) {
417
			$trackPool = $this->findTracksByGenre($genre);
418
		} else {
419
			$trackPool = $this->trackBusinessLayer->findAll($this->user());
420
		}
421
422
		if ($fromYear !== null) {
423
			$trackPool = \array_filter($trackPool, fn($track) => ($track->getYear() !== null && $track->getYear() >= $fromYear));
424
		}
425
426
		if ($toYear !== null) {
427
			$trackPool = \array_filter($trackPool, fn($track) => ($track->getYear() !== null && $track->getYear() <= $toYear));
428
		}
429
430
		$tracks = Random::pickItems($trackPool, $size);
431
432
		return ['randomSongs' => [
433
			'song' => $this->tracksToApi($tracks)
434
		]];
435
	}
436
437
	/**
438
	 * @SubsonicAPI
439
	 */
440
	protected function getCoverArt(string $id, ?int $size) : Response {
441
		list($type, $entityId) = self::parseEntityId($id);
442
		$userId = $this->user();
443
444
		if ($type == 'album') {
445
			$entity = $this->albumBusinessLayer->find($entityId, $userId);
446
		} elseif ($type == 'artist') {
447
			$entity = $this->artistBusinessLayer->find($entityId, $userId);
448
		} elseif ($type == 'podcast_channel') {
449
			$entity = $this->podcastService->getChannel($entityId, $userId, /*$includeEpisodes=*/ false);
450
		} elseif ($type == 'pl') {
451
			$entity = $this->playlistBusinessLayer->find($entityId, $userId);
452
		}
453
454
		if (!empty($entity)) {
455
			$rootFolder = $this->librarySettings->getFolder($userId);
456
			$coverData = $this->coverService->getCover($entity, $userId, $rootFolder, $size);
457
			$response = new FileResponse($coverData);
458
			HttpUtil::setClientCachingDays($response, 30);
459
			return $response;
460
		}
461
462
		return $this->subsonicErrorResponse(70, "entity $id has no cover");
463
	}
464
465
	/**
466
	 * @SubsonicAPI
467
	 */
468
	protected function getLyrics(?string $artist, ?string $title) : array {
469
		$userId = $this->user();
470
		$matches = $this->trackBusinessLayer->findAllByNameArtistOrAlbum($title, $artist, null, $userId);
471
		$matchingCount = \count($matches);
472
473
		if ($matchingCount === 0) {
474
			$this->logger->debug("No matching track for title '$title' and artist '$artist'");
475
			return ['lyrics' => new \stdClass];
476
		} else {
477
			if ($matchingCount > 1) {
478
				$this->logger->debug("Found $matchingCount tracks matching title ".
479
								"'$title' and artist '$artist'; using the first");
480
			}
481
			$track = $matches[0];
482
483
			$artistObj = $this->artistBusinessLayer->find($track->getArtistId(), $userId);
484
			$rootFolder = $this->librarySettings->getFolder($userId);
485
			$lyrics = $this->detailsService->getLyricsAsPlainText($track->getFileId(), $rootFolder);
486
487
			return ['lyrics' => [
488
				'artist' => $artistObj->getNameString($this->l10n),
489
				'title' => $track->getTitle(),
490
				'value' => $lyrics
491
			]];
492
		}
493
	}
494
495
	/**
496
	 * OpenSubsonic extension
497
	 * @SubsonicAPI
498
	 */
499
	protected function getLyricsBySongId(string $id) : array {
500
		$userId = $this->user();
501
		$trackId = self::ripIdPrefix($id); // get rid of 'track-' prefix
502
		$track = $this->trackBusinessLayer->find($trackId, $userId);
503
		$artist = $this->artistBusinessLayer->find($track->getArtistId(), $userId);
504
		$rootFolder = $this->librarySettings->getFolder($userId);
505
		$allLyrics = $this->detailsService->getLyricsAsStructured($track->getFileId(), $rootFolder);
506
507
		return ['lyricsList' => [
508
			'structuredLyrics' => \array_map(function ($lyrics) use ($track, $artist) {
509
				$isSynced = $lyrics['synced'];
510
				return [
511
					'displayArtist' => $artist->getNameString($this->l10n),
512
					'displayTitle' => $track->getTitle(),
513
					'lang' => 'xxx',
514
					'offset' => 0,
515
					'synced' => $isSynced,
516
					'line' => \array_map(function($lineVal, $lineKey) use ($isSynced) {
517
						$line = ['value' => \trim($lineVal)];
518
						if ($isSynced) {
519
							$line['start'] = $lineKey;
520
						};
521
						return $line;
522
					}, $lyrics['lines'], \array_keys($lyrics['lines']))
523
				];
524
			}, $allLyrics)
525
		]];
526
	}
527
528
	/**
529
	 * @SubsonicAPI
530
	 */
531
	protected function stream(string $id) : Response {
532
		// We don't support transcoding, so 'stream' and 'download' act identically
533
		return $this->download($id);
534
	}
535
536
	/**
537
	 * @SubsonicAPI
538
	 */
539
	protected function download(string $id) : Response {
540
		list($type, $entityId) = self::parseEntityId($id);
541
542
		if ($type === 'track') {
543
			$track = $this->trackBusinessLayer->find($entityId, $this->user());
544
			$file = $this->getFilesystemNode($track->getFileId());
545
546
			if ($file instanceof File) {
547
				return new FileStreamResponse($file);
548
			} else {
549
				return $this->subsonicErrorResponse(70, 'file not found');
550
			}
551
		} elseif ($type === 'podcast_episode') {
552
			$episode = $this->podcastService->getEpisode($entityId, $this->user());
553
			if ($episode instanceof PodcastEpisode) {
554
				return new RedirectResponse($episode->getStreamUrl());
555
			} else {
556
				return $this->subsonicErrorResponse(70, 'episode not found');
557
			}
558
		} else {
559
			return $this->subsonicErrorResponse(0, "id of type $type not supported");
560
		}
561
	}
562
563
	/**
564
	 * @SubsonicAPI
565
	 */
566
	protected function search2(string $query, int $artistCount=20, int $artistOffset=0,
567
			int $albumCount=20, int $albumOffset=0, int $songCount=20, int $songOffset=0) : array {
568
		$results = $this->doSearch($query, $artistCount, $artistOffset, $albumCount, $albumOffset, $songCount, $songOffset);
569
		return $this->searchResponse('searchResult2', $results, /*$useNewApi=*/false);
570
	}
571
572
	/**
573
	 * @SubsonicAPI
574
	 */
575
	protected function search3(string $query, int $artistCount=20, int $artistOffset=0,
576
			int $albumCount=20, int $albumOffset=0, int $songCount=20, int $songOffset=0) : array {
577
		$results = $this->doSearch($query, $artistCount, $artistOffset, $albumCount, $albumOffset, $songCount, $songOffset);
578
		return $this->searchResponse('searchResult3', $results, /*$useNewApi=*/true);
579
	}
580
581
	/**
582
	 * @SubsonicAPI
583
	 */
584
	protected function getGenres() : array {
585
		$genres = $this->genreBusinessLayer->findAll($this->user(), SortBy::Name);
586
587
		return ['genres' => [
588
			'genre' => \array_map(fn($genre) => [
589
				'songCount' => $genre->getTrackCount(),
590
				'albumCount' => $genre->getAlbumCount(),
591
				'value' => $genre->getNameString($this->l10n)
592
			], $genres)
593
		]];
594
	}
595
596
	/**
597
	 * @SubsonicAPI
598
	 */
599
	protected function getSongsByGenre(string $genre, int $count=10, int $offset=0) : array {
600
		$tracks = $this->findTracksByGenre($genre, $count, $offset);
601
602
		return ['songsByGenre' => [
603
			'song' => $this->tracksToApi($tracks)
604
		]];
605
	}
606
607
	/**
608
	 * @SubsonicAPI
609
	 */
610
	protected function getPlaylists() : array {
611
		$userId = $this->user();
612
		$playlists = $this->playlistBusinessLayer->findAll($userId);
613
614
		foreach ($playlists as $playlist) {
615
			$playlist->setDuration($this->playlistBusinessLayer->getDuration($playlist->getId(), $userId));
616
		}
617
618
		return ['playlists' => [
619
			'playlist' => \array_map(fn($p) => $p->toSubsonicApi(), $playlists)
620
		]];
621
	}
622
623
	/**
624
	 * @SubsonicAPI
625
	 */
626
	protected function getPlaylist(int $id) : array {
627
		$userId = $this->user();
628
		$playlist = $this->playlistBusinessLayer->find($id, $userId);
629
		$tracks = $this->playlistBusinessLayer->getPlaylistTracks($id, $userId);
630
		$playlist->setDuration(\array_reduce($tracks, function (?int $accuDuration, Track $track) : int {
631
			return (int)$accuDuration + (int)$track->getLength();
632
		}));
633
634
		$playlistNode = $playlist->toSubsonicApi();
635
		$playlistNode['entry'] = $this->tracksToApi($tracks);
636
637
		return ['playlist' => $playlistNode];
638
	}
639
640
	/**
641
	 * @SubsonicAPI
642
	 */
643
	protected function createPlaylist(?string $name, ?string $playlistId, array $songId) : array {
644
		$songIds = \array_map('self::ripIdPrefix', $songId);
645
646
		// If playlist ID has been passed, then this method actually updates an existing list instead of creating a new one.
647
		// 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).
648
		if (!empty($playlistId)) {
649
			$playlistId = (int)$playlistId;
650
		} elseif (!empty($name)) {
651
			$playlist = $this->playlistBusinessLayer->create($name, $this->user());
652
			$playlistId = $playlist->getId();
653
		} else {
654
			throw new SubsonicException('Playlist ID or name must be specified.', 10);
655
		}
656
657
		$this->playlistBusinessLayer->setTracks($songIds, $playlistId, $this->user());
658
659
		return $this->getPlaylist($playlistId);
660
	}
661
662
	/**
663
	 * @SubsonicAPI
664
	 */
665
	protected function updatePlaylist(int $playlistId, ?string $name, ?string $comment, array $songIdToAdd, array $songIndexToRemove) : array {
666
		$songIdsToAdd = \array_map('self::ripIdPrefix', $songIdToAdd);
667
		$songIndicesToRemove = \array_map('intval', $songIndexToRemove);
668
		$userId = $this->user();
669
670
		if (!empty($name)) {
671
			$this->playlistBusinessLayer->rename($name, $playlistId, $userId);
672
		}
673
674
		if ($comment !== null) {
675
			$this->playlistBusinessLayer->setComment($comment, $playlistId, $userId);
676
		}
677
678
		if (!empty($songIndicesToRemove)) {
679
			$this->playlistBusinessLayer->removeTracks($songIndicesToRemove, $playlistId, $userId);
680
		}
681
682
		if (!empty($songIdsToAdd)) {
683
			$this->playlistBusinessLayer->addTracks($songIdsToAdd, $playlistId, $userId);
684
		}
685
686
		return [];
687
	}
688
689
	/**
690
	 * @SubsonicAPI
691
	 */
692
	protected function deletePlaylist(int $id) : array {
693
		$this->playlistBusinessLayer->delete($id, $this->user());
694
		return [];
695
	}
696
697
	/**
698
	 * @SubsonicAPI
699
	 */
700
	protected function getInternetRadioStations() : array {
701
		$stations = $this->radioStationBusinessLayer->findAll($this->user());
702
703
		return ['internetRadioStations' => [
704
			'internetRadioStation' => \array_map(fn($station) => [
705
				'id' => $station->getId(),
706
				'name' => $station->getName() ?: $station->getStreamUrl(),
707
				'streamUrl' => $station->getStreamUrl(),
708
				'homePageUrl' => $station->getHomeUrl()
709
			], $stations)
710
		]];
711
	}
712
713
	/**
714
	 * @SubsonicAPI
715
	 */
716
	protected function createInternetRadioStation(string $streamUrl, string $name, ?string $homepageUrl) : array {
717
		$this->radioStationBusinessLayer->create($this->user(), $name, $streamUrl, $homepageUrl);
718
		return [];
719
	}
720
721
	/**
722
	 * @SubsonicAPI
723
	 */
724
	protected function updateInternetRadioStation(int $id, string $streamUrl, string $name, ?string $homepageUrl) : array {
725
		$this->radioStationBusinessLayer->updateStation($id, $this->user(), $name, $streamUrl, $homepageUrl);
726
		return [];
727
	}
728
729
	/**
730
	 * @SubsonicAPI
731
	 */
732
	protected function deleteInternetRadioStation(int $id) : array {
733
		$this->radioStationBusinessLayer->delete($id, $this->user());
734
		return [];
735
	}
736
737
	/**
738
	 * @SubsonicAPI
739
	 */
740
	protected function getUser(string $username) : array {
741
		$userId = $this->user();
742
		if (\mb_strtolower($username) != \mb_strtolower($userId)) {
743
			throw new SubsonicException("$userId is not authorized to get details for other users.", 50);
744
		}
745
746
		$user = $this->userManager->get($userId);
747
748
		return [
749
			'user' => [
750
				'username' => $userId,
751
				'email' => $user->getEMailAddress(),
752
				'scrobblingEnabled' => true,
753
				'adminRole' => false,
754
				'settingsRole' => false,
755
				'downloadRole' => true,
756
				'uploadRole' => false,
757
				'playlistRole' => true,
758
				'coverArtRole' => false,
759
				'commentRole' => true,
760
				'podcastRole' => true,
761
				'streamRole' => true,
762
				'jukeboxRole' => false,
763
				'shareRole' => false,
764
				'videoConversionRole' => false,
765
				'folder' => [self::FOLDER_ID_ARTISTS, self::FOLDER_ID_FOLDERS],
766
			]
767
		];
768
	}
769
770
	/**
771
	 * @SubsonicAPI
772
	 */
773
	protected function getUsers() : array {
774
		throw new SubsonicException("{$this->user()} is not authorized to get details for other users.", 50);
775
	}
776
777
	/**
778
	 * @SubsonicAPI
779
	 */
780
	protected function getAvatar(string $username) : Response {
781
		$userId = $this->user();
782
		if (\mb_strtolower($username) != \mb_strtolower($userId)) {
783
			throw new SubsonicException("$userId is not authorized to get avatar for other users.", 50);
784
		}
785
786
		$image = $this->userManager->get($userId)->getAvatarImage(150);
787
788
		if ($image !== null) {
789
			return new FileResponse(['content' => $image->data(), 'mimetype' => $image->mimeType()]);
790
		} else {
791
			return $this->subsonicErrorResponse(70, 'user has no avatar');
792
		}
793
	}
794
795
	/**
796
	 * OpenSubsonic extension
797
	 * @SubsonicAPI
798
	 */
799
	protected function tokenInfo() : array {
800
		// This method is intended to be used when API key is used for authentication and the user name is not
801
		// directly available for the client. But it shouldn't hurt to allow calling this regardless of the
802
		// authentication method.
803
		return ['tokenInfo' => ['username' => $this->user()]];
804
	}
805
806
	/**
807
	 * @SubsonicAPI
808
	 */
809
	protected function scrobble(array $id, array $time) : array {
810
		if (\count($id) === 0) {
811
			throw new SubsonicException("Required parameter 'id' missing", 10);
812
		}
813
814
		$userId = $this->user();
815
		foreach ($id as $index => $aId) {
816
			list($type, $trackId) = self::parseEntityId($aId);
817
			if ($type === 'track') {
818
				if (isset($time[$index])) {
819
					$timestamp = \substr($time[$index], 0, -3); // cut down from milliseconds to seconds
820
					$timeOfPlay = new \DateTime('@' . $timestamp);
821
				} else {
822
					$timeOfPlay = null;
823
				}
824
				$this->trackBusinessLayer->recordTrackPlayed((int)$trackId, $userId, $timeOfPlay);
825
			}
826
		}
827
828
		return [];
829
	}
830
831
	/**
832
	 * @SubsonicAPI
833
	 */
834
	protected function star(array $id, array $albumId, array $artistId) : array {
835
		$targetIds = self::parseStarringParameters($id, $albumId, $artistId);
836
		$userId = $this->user();
837
838
		$this->trackBusinessLayer->setStarred($targetIds['tracks'], $userId);
839
		$this->albumBusinessLayer->setStarred($targetIds['albums'], $userId);
840
		$this->artistBusinessLayer->setStarred($targetIds['artists'], $userId);
841
		$this->podcastChannelBusinessLayer->setStarred($targetIds['podcast_channels'], $userId);
842
		$this->podcastEpisodeBusinessLayer->setStarred($targetIds['podcast_episodes'], $userId);
843
844
		return [];
845
	}
846
847
	/**
848
	 * @SubsonicAPI
849
	 */
850
	protected function unstar(array $id, array $albumId, array $artistId) : array {
851
		$targetIds = self::parseStarringParameters($id, $albumId, $artistId);
852
		$userId = $this->user();
853
854
		$this->trackBusinessLayer->unsetStarred($targetIds['tracks'], $userId);
855
		$this->albumBusinessLayer->unsetStarred($targetIds['albums'], $userId);
856
		$this->artistBusinessLayer->unsetStarred($targetIds['artists'], $userId);
857
		$this->podcastChannelBusinessLayer->unsetStarred($targetIds['podcast_channels'], $userId);
858
		$this->podcastEpisodeBusinessLayer->unsetStarred($targetIds['podcast_episodes'], $userId);
859
860
		return [];
861
	}
862
863
	/**
864
	 * @SubsonicAPI
865
	 */
866
	protected function setRating(string $id, int $rating) : array {
867
		$rating = (int)Util::limit($rating, 0, 5);
868
		list($type, $entityId) = self::parseEntityId($id);
869
870
		switch ($type) {
871
			case 'track':
872
				$bLayer = $this->trackBusinessLayer;
873
				break;
874
			case 'album':
875
				$bLayer = $this->albumBusinessLayer;
876
				break;
877
			case 'artist':
878
				$bLayer = $this->artistBusinessLayer;
879
				break;
880
			case 'podcast_episode':
881
				$bLayer = $this->podcastEpisodeBusinessLayer;
882
				break;
883
			case 'folder':
884
				throw new SubsonicException('Rating folders is not supported', 0);
885
			default:
886
				throw new SubsonicException("Unexpected ID format: $id", 0);
887
		}
888
889
		$bLayer->setRating($entityId, $rating, $this->user());
890
891
		return [];
892
	}
893
894
	/**
895
	 * @SubsonicAPI
896
	 */
897
	protected function getStarred() : array {
898
		$starred = $this->doGetStarred();
899
		return $this->searchResponse('starred', $starred, /*$useNewApi=*/false);
900
	}
901
902
	/**
903
	 * @SubsonicAPI
904
	 */
905
	protected function getStarred2() : array {
906
		$starred = $this->doGetStarred();
907
		return $this->searchResponse('starred2', $starred, /*$useNewApi=*/true);
908
	}
909
910
	/**
911
	 * @SubsonicAPI
912
	 */
913
	protected function getVideos() : array {
914
		// Feature not supported, return an empty list
915
		return [
916
			'videos' => [
917
				'video' => []
918
			]
919
		];
920
	}
921
922
	/**
923
	 * @SubsonicAPI
924
	 */
925
	protected function getPodcasts(?string $id, bool $includeEpisodes = true) : array {
926
		if ($id !== null) {
927
			$id = self::ripIdPrefix($id);
928
			$channel = $this->podcastService->getChannel($id, $this->user(), $includeEpisodes);
929
			if ($channel === null) {
930
				throw new SubsonicException('Requested channel not found', 70);
931
			}
932
			$channels = [$channel];
933
		} else {
934
			$channels = $this->podcastService->getAllChannels($this->user(), $includeEpisodes);
935
		}
936
937
		return [
938
			'podcasts' => [
939
				'channel' => \array_map(fn($c) => $c->toSubsonicApi(), $channels)
940
			]
941
		];
942
	}
943
944
	/**
945
	 * OpenSubsonic extension
946
	 * @SubsonicAPI
947
	 */
948
	protected function getPodcastEpisode(string $id) : array {
949
		$id = self::ripIdPrefix($id);
950
		$episode = $this->podcastService->getEpisode($id, $this->user());
951
952
		if ($episode === null) {
953
			throw new SubsonicException('Requested episode not found', 70);
954
		}
955
956
		return [
957
			'podcastEpisode' => $episode->toSubsonicApi()
958
		];
959
	}
960
961
	/**
962
	 * @SubsonicAPI
963
	 */
964
	protected function getNewestPodcasts(int $count=20) : array {
965
		$episodes = $this->podcastService->getLatestEpisodes($this->user(), $count);
966
967
		return [
968
			'newestPodcasts' => [
969
				'episode' => \array_map(fn($e) => $e->toSubsonicApi(), $episodes)
970
			]
971
		];
972
	}
973
974
	/**
975
	 * @SubsonicAPI
976
	 */
977
	protected function refreshPodcasts() : array {
978
		$this->podcastService->updateAllChannels($this->user());
979
		return [];
980
	}
981
982
	/**
983
	 * @SubsonicAPI
984
	 */
985
	protected function createPodcastChannel(string $url) : array {
986
		$result = $this->podcastService->subscribe($url, $this->user());
987
988
		switch ($result['status']) {
989
			case PodcastService::STATUS_OK:
990
				return [];
991
			case PodcastService::STATUS_INVALID_URL:
992
				throw new SubsonicException("Invalid URL $url", 0);
993
			case PodcastService::STATUS_INVALID_RSS:
994
				throw new SubsonicException("The document at URL $url is not a valid podcast RSS feed", 0);
995
			case PodcastService::STATUS_ALREADY_EXISTS:
996
				throw new SubsonicException('User already has this podcast channel subscribed', 0);
997
			default:
998
				throw new SubsonicException("Unexpected status code {$result['status']}", 0);
999
		}
1000
	}
1001
1002
	/**
1003
	 * @SubsonicAPI
1004
	 */
1005
	protected function deletePodcastChannel(string $id) : array {
1006
		$id = self::ripIdPrefix($id);
1007
		$status = $this->podcastService->unsubscribe($id, $this->user());
1008
1009
		switch ($status) {
1010
			case PodcastService::STATUS_OK:
1011
				return [];
1012
			case PodcastService::STATUS_NOT_FOUND:
1013
				throw new SubsonicException('Channel to be deleted not found', 70);
1014
			default:
1015
				throw new SubsonicException("Unexpected status code $status", 0);
1016
		}
1017
	}
1018
1019
	/**
1020
	 * @SubsonicAPI
1021
	 */
1022
	protected function getBookmarks() : array {
1023
		$userId = $this->user();
1024
		$bookmarkNodes = [];
1025
		$bookmarks = $this->bookmarkBusinessLayer->findAll($userId);
1026
1027
		foreach ($bookmarks as $bookmark) {
1028
			$node = $bookmark->toSubsonicApi();
1029
			$entryId = $bookmark->getEntryId();
1030
			$type = $bookmark->getType();
1031
1032
			try {
1033
				if ($type === Bookmark::TYPE_TRACK) {
1034
					$track = $this->trackBusinessLayer->find($entryId, $userId);
1035
					$node['entry'] = $this->trackToApi($track);
1036
				} elseif ($type === Bookmark::TYPE_PODCAST_EPISODE) {
1037
					$node['entry'] = $this->podcastEpisodeBusinessLayer->find($entryId, $userId)->toSubsonicApi();
1038
				} else {
1039
					$this->logger->warning("Bookmark {$bookmark->getId()} had unexpected entry type $type");
1040
				}
1041
				$bookmarkNodes[] = $node;
1042
			} catch (BusinessLayerException $e) {
1043
				$this->logger->warning("Bookmarked entry with type $type and id $entryId not found");
1044
			}
1045
		}
1046
1047
		return ['bookmarks' => ['bookmark' => $bookmarkNodes]];
1048
	}
1049
1050
	/**
1051
	 * @SubsonicAPI
1052
	 */
1053
	protected function createBookmark(string $id, int $position, ?string $comment) : array {
1054
		list($type, $entityId) = self::parseBookmarkIdParam($id);
1055
		$this->bookmarkBusinessLayer->addOrUpdate($this->user(), $type, $entityId, $position, $comment);
1056
		return [];
1057
	}
1058
1059
	/**
1060
	 * @SubsonicAPI
1061
	 */
1062
	protected function deleteBookmark(string $id) : array {
1063
		list($type, $entityId) = self::parseBookmarkIdParam($id);
1064
1065
		$bookmark = $this->bookmarkBusinessLayer->findByEntry($type, $entityId, $this->user());
1066
		$this->bookmarkBusinessLayer->delete($bookmark->getId(), $this->user());
1067
1068
		return [];
1069
	}
1070
1071
	/**
1072
	 * @SubsonicAPI
1073
	 */
1074
	protected function getPlayQueue() : Response {
1075
		/** @var array|false $playQueue */
1076
		$playQueue = json_decode($this->configManager->getUserValue($this->user(), $this->appName, 'play_queue', 'false'), true);
1077
1078
		if (!$playQueue) {
1079
			return $this->subsonicResponse([]);
1080
		}
1081
1082
		$parsedEntries = \array_map([self::class, 'parseEntityId'], $playQueue['entry']);
1083
		$trackEntries = \array_filter($parsedEntries,fn ($parsedEntry) => $parsedEntry[0] === 'track');
1084
		$tracksById = ArrayUtil::createIdLookupTable($this->trackBusinessLayer->findById(
1085
			\array_map(fn ($trackEntry) => $trackEntry[1], $trackEntries), $this->user()
1086
		));
1087
		$apiTracks = $this->tracksToApi($tracksById);
1088
1089
		$playQueue['entry'] = \array_reduce(
1090
			$parsedEntries,
1091
			function ($pqEntries, $parsedEntry) use ($apiTracks) {
1092
				[$type, $id] = $parsedEntry;
1093
				if ($type === 'track' && isset($apiTracks[$id])) {
1094
					$pqEntries[] = $apiTracks[$id];
1095
				} else if ($type === 'podcast_episode') {
1096
					try {
1097
						$pqEntries[] = $this->podcastEpisodeBusinessLayer->find($id, $this->user())->toSubsonicApi();
1098
					} catch (\Throwable $t) {
1099
						// catch missing podcast episode exceptions; maybe episode was deleted after being added to queue
1100
					}
1101
				}
1102
				return $pqEntries;
1103
			},
1104
			[]
1105
		);
1106
1107
		return $this->subsonicResponse(['playQueue' => $playQueue]);
1108
	}
1109
1110
	/**
1111
	 * @SubsonicAPI
1112
	 */
1113
	protected function savePlayQueue(array $id, string $c, string $u, ?string $current = null, ?int $position = null) : Response {
1114
		$changedDateTime = new \DateTime();
1115
		$playQueue = array_filter([
1116
			'entry' => array_filter(
1117
				$id,
1118
				fn (string $entityId) => in_array(self::parseEntityId($entityId)[0], ['track', 'podcast_episode'])
1119
			),
1120
			'changedBy' => $c,
1121
			'position' => $position,
1122
			'current' => $current,
1123
			/** @see Util::formatZuluDateTime (if only we could pass a datetime!) */
1124
			'changed' => $changedDateTime->format('Y-m-d\TH:i:s.v\Z'),
1125
			'username' => $u
1126
		], fn ($val) => $val !== null);
1127
1128
		$playQueueJson = json_encode($playQueue, \JSON_THROW_ON_ERROR);
1129
		$this->configManager->setUserValue($this->userId, $this->appName, 'play_queue', $playQueueJson);
1130
1131
		return $this->subsonicResponse([]);
1132
	}
1133
1134
	/**
1135
	 * @SubsonicAPI
1136
	 */
1137
	protected function getScanStatus() : array {
1138
		return ['scanStatus' => [
1139
			'scanning' => false,
1140
			'count' => $this->trackBusinessLayer->count($this->user())
1141
		]];
1142
	}
1143
1144
	/**
1145
	 * @SubsonicAPI
1146
	 */
1147
	protected function getNowPlaying() : array {
1148
		// TODO: not supported yet
1149
		return ['nowPlaying' => ['entry' => []]];
1150
	}
1151
1152
	/**
1153
	 * @SubsonicAPI
1154
	 */
1155
	protected function getOpenSubsonicExtensions() : array {
1156
		return ['openSubsonicExtensions' => [
1157
			[ 'name' => 'apiKeyAuthentication', 'versions' => [1] ],
1158
			[ 'name' => 'formPost', 'versions' => [1] ],
1159
			[ 'name' => 'getPodcastEpisode', 'versions' => [1] ],
1160
			[ 'name' => 'songLyrics', 'versions' => [1] ],
1161
		]];
1162
	}
1163
1164
	/* -------------------------------------------------------------------------
1165
	 * Helper methods
1166
	 * -------------------------------------------------------------------------
1167
	 */
1168
1169
	/**
1170
	 * @param string|int|null $paramValue
1171
	 */
1172
	private static function ensureParamHasValue(string $paramName, /*mixed*/ $paramValue) : void {
1173
		if ($paramValue === null || $paramValue === '') {
1174
			throw new SubsonicException("Required parameter '$paramName' missing", 10);
1175
		}
1176
	}
1177
1178
	private static function parseBookmarkIdParam(string $id) : array {
1179
		list($typeName, $entityId) = self::parseEntityId($id);
1180
1181
		if ($typeName === 'track') {
1182
			$type = Bookmark::TYPE_TRACK;
1183
		} elseif ($typeName === 'podcast_episode') {
1184
			$type = Bookmark::TYPE_PODCAST_EPISODE;
1185
		} else {
1186
			throw new SubsonicException("Unsupported ID format $id", 0);
1187
		}
1188
1189
		return [$type, $entityId];
1190
	}
1191
1192
	/**
1193
	 * Parse parameters used in the `star` and `unstar` API methods
1194
	 */
1195
	private static function parseStarringParameters(array $ids, array $albumIds, array $artistIds) : array {
1196
		// album IDs from newer clients
1197
		$albumIds = \array_map('self::ripIdPrefix', $albumIds);
1198
1199
		// artist IDs from newer clients
1200
		$artistIds = \array_map('self::ripIdPrefix', $artistIds);
1201
1202
		// Song IDs from newer clients and song/folder/album/artist IDs from older clients are all packed in $ids.
1203
		// Also podcast IDs may come there; that is not documented part of the API but at least DSub does that.
1204
1205
		$trackIds = [];
1206
		$channelIds = [];
1207
		$episodeIds = [];
1208
1209
		foreach ($ids as $prefixedId) {
1210
			list($type, $id) = self::parseEntityId($prefixedId);
1211
1212
			if ($type == 'track') {
1213
				$trackIds[] = $id;
1214
			} elseif ($type == 'album') {
1215
				$albumIds[] = $id;
1216
			} elseif ($type == 'artist') {
1217
				$artistIds[] = $id;
1218
			} elseif ($type == 'podcast_channel') {
1219
				$channelIds[] = $id;
1220
			} elseif ($type == 'podcast_episode') {
1221
				$episodeIds[] = $id;
1222
			} elseif ($type == 'folder') {
1223
				throw new SubsonicException('Starring folders is not supported', 0);
1224
			} else {
1225
				throw new SubsonicException("Unexpected ID format: $prefixedId", 0);
1226
			}
1227
		}
1228
1229
		return [
1230
			'tracks' => $trackIds,
1231
			'albums' => $albumIds,
1232
			'artists' => $artistIds,
1233
			'podcast_channels' => $channelIds,
1234
			'podcast_episodes' => $episodeIds
1235
		];
1236
	}
1237
1238
	private function user() : string {
1239
		if ($this->userId === null) {
1240
			throw new SubsonicException('User authentication required', 10);
1241
		}
1242
		return $this->userId;
1243
	}
1244
1245
	private function getFilesystemNode(int $id) : Node {
1246
		$rootFolder = $this->librarySettings->getFolder($this->user());
1247
		$nodes = $rootFolder->getById($id);
1248
1249
		if (\count($nodes) != 1) {
1250
			throw new SubsonicException('file not found', 70);
1251
		}
1252
1253
		return $nodes[0];
1254
	}
1255
1256
	private function nameWithoutArticle(?string $name) : ?string {
1257
		return StringUtil::splitPrefixAndBasename($name, $this->ignoredArticles)['basename'];
1258
	}
1259
1260
	private static function getIndexingChar(?string $name) : string {
1261
		// For unknown artists, use '?'
1262
		$char = '?';
1263
1264
		if (!empty($name)) {
1265
			$char = \mb_convert_case(\mb_substr($name, 0, 1), MB_CASE_UPPER);
1266
		}
1267
		// Bundle all numeric characters together
1268
		if (\is_numeric($char)) {
1269
			$char = '#';
1270
		}
1271
1272
		return $char;
1273
	}
1274
1275
	private function getSubFoldersAndTracks(Folder $folder) : array {
1276
		$nodes = $folder->getDirectoryListing();
1277
		$subFolders = \array_filter($nodes, fn($n) =>
1278
			($n instanceof Folder) && $this->librarySettings->pathBelongsToMusicLibrary($n->getPath(), $this->user())
1279
		);
1280
1281
		$tracks = $this->trackBusinessLayer->findAllByFolder($folder->getId(), $this->user());
1282
1283
		return [$subFolders, $tracks];
1284
	}
1285
1286
	private function getIndexesForFolders() : array {
1287
		$rootFolder = $this->librarySettings->getFolder($this->user());
1288
1289
		list($subFolders, $tracks) = $this->getSubFoldersAndTracks($rootFolder);
1290
1291
		$indexes = [];
1292
		foreach ($subFolders as $folder) {
1293
			$sortName = $this->nameWithoutArticle($folder->getName());
1294
			$indexes[self::getIndexingChar($sortName)][] = [
1295
				'sortName' => $sortName,
1296
				'artist' => [
1297
					'name' => $folder->getName(),
1298
					'id' => 'folder-' . $folder->getId()
1299
				]
1300
			];
1301
		}
1302
		\ksort($indexes, SORT_LOCALE_STRING);
1303
1304
		$folders = [];
1305
		foreach ($indexes as $indexChar => $bucketArtists) {
1306
			ArrayUtil::sortByColumn($bucketArtists, 'sortName');
1307
			$folders[] = ['name' => $indexChar, 'artist' => \array_column($bucketArtists, 'artist')];
1308
		}
1309
1310
		return ['indexes' => [
1311
			'ignoredArticles' => \implode(' ', $this->ignoredArticles),
1312
			'index' => $folders,
1313
			'child' => $this->tracksToApi($tracks)
1314
		]];
1315
	}
1316
1317
	private function getMusicDirectoryForFolder(string $id) : array {
1318
		$folderId = self::ripIdPrefix($id); // get rid of 'folder-' prefix
1319
		$folder = $this->getFilesystemNode($folderId);
1320
1321
		if (!($folder instanceof Folder)) {
1322
			throw new SubsonicException("$id is not a valid folder", 70);
1323
		}
1324
1325
		list($subFolders, $tracks) = $this->getSubFoldersAndTracks($folder);
1326
1327
		$children = \array_merge(
1328
			\array_map([$this, 'folderToApi'], $subFolders),
1329
			$this->tracksToApi($tracks)
1330
		);
1331
1332
		$content = [
1333
			'directory' => [
1334
				'id' => $id,
1335
				'name' => $folder->getName(),
1336
				'child' => $children
1337
			]
1338
		];
1339
1340
		// Parent folder ID is included if and only if the parent folder is not the top level
1341
		$rootFolderId = $this->librarySettings->getFolder($this->user())->getId();
1342
		$parentFolderId = $folder->getParent()->getId();
1343
		if ($rootFolderId != $parentFolderId) {
1344
			$content['parent'] = 'folder-' . $parentFolderId;
1345
		}
1346
1347
		return $content;
1348
	}
1349
1350
	private function getIndexesForArtists(string $rootElementName = 'indexes') : array {
1351
		$artists = $this->artistBusinessLayer->findAllHavingAlbums($this->user(), SortBy::Name);
1352
1353
		$indexes = [];
1354
		foreach ($artists as $artist) {
1355
			$sortName = $this->nameWithoutArticle($artist->getName());
1356
			$indexes[self::getIndexingChar($sortName)][] = ['sortName' => $sortName, 'artist' => $this->artistToApi($artist)];
1357
		}
1358
		\ksort($indexes, SORT_LOCALE_STRING);
1359
1360
		$result = [];
1361
		foreach ($indexes as $indexChar => $bucketArtists) {
1362
			ArrayUtil::sortByColumn($bucketArtists, 'sortName');
1363
			$result[] = ['name' => $indexChar, 'artist' => \array_column($bucketArtists, 'artist')];
1364
		}
1365
1366
		return [$rootElementName => [
1367
			'ignoredArticles' => \implode(' ', $this->ignoredArticles),
1368
			'index' => $result
1369
		]];
1370
	}
1371
1372
	private function getMusicDirectoryForArtist(string $id) : array {
1373
		$artistId = self::ripIdPrefix($id); // get rid of 'artist-' prefix
1374
1375
		$artist = $this->artistBusinessLayer->find($artistId, $this->user());
1376
		$albums = $this->albumBusinessLayer->findAllByArtist($artistId, $this->user());
1377
1378
		return [
1379
			'directory' => [
1380
				'id' => $id,
1381
				'name' => $artist->getNameString($this->l10n),
1382
				'child' => \array_map([$this, 'albumToOldApi'], $albums)
1383
			]
1384
		];
1385
	}
1386
1387
	private function getMusicDirectoryForAlbum(string $id) : array {
1388
		$albumId = self::ripIdPrefix($id); // get rid of 'album-' prefix
1389
1390
		$album = $this->albumBusinessLayer->find($albumId, $this->user());
1391
		$albumName = $album->getNameString($this->l10n);
1392
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $this->user());
1393
1394
		return [
1395
			'directory' => [
1396
				'id' => $id,
1397
				'parent' => 'artist-' . $album->getAlbumArtistId(),
1398
				'name' => $albumName,
1399
				'child' => $this->tracksToApi($tracks)
1400
			]
1401
		];
1402
	}
1403
1404
	private function getMusicDirectoryForPodcastChannel(string $id) : array {
1405
		$channelId = self::ripIdPrefix($id); // get rid of 'podcast_channel-' prefix
1406
		$channel = $this->podcastService->getChannel($channelId, $this->user(), /*$includeEpisodes=*/ true);
1407
1408
		if ($channel === null) {
1409
			throw new SubsonicException("Podcast channel $channelId not found", 0);
1410
		}
1411
1412
		return [
1413
			'directory' => [
1414
				'id' => $id,
1415
				'name' => $channel->getTitle(),
1416
				'child' => \array_map(fn($e) => $e->toSubsonicApi(), $channel->getEpisodes() ?? [])
1417
			]
1418
		];
1419
	}
1420
1421
	private function folderToApi(Folder $folder) : array {
1422
		return [
1423
			'id' => 'folder-' . $folder->getId(),
1424
			'title' => $folder->getName(),
1425
			'isDir' => true
1426
		];
1427
	}
1428
1429
	private function artistToApi(Artist $artist) : array {
1430
		$id = $artist->getId();
1431
		$result = [
1432
			'name' => $artist->getNameString($this->l10n),
1433
			'id' => $id ? ('artist-' . $id) : '-1', // getArtistInfo may show artists without ID
1434
			'albumCount' => $id ? $this->albumBusinessLayer->countByArtist($id) : 0,
1435
			'starred' => Util::formatZuluDateTime($artist->getStarred()),
1436
			'userRating' => $artist->getRating() ?: null,
1437
			'averageRating' => $artist->getRating() ?: null,
1438
			'sortName' => $this->nameWithoutArticle($artist->getName()) ?? '', // OpenSubsonic
1439
		];
1440
1441
		if (!empty($artist->getCoverFileId())) {
1442
			$result['coverArt'] = $result['id'];
1443
			$result['artistImageUrl'] = $this->artistImageUrl($id);
1444
		}
1445
1446
		return $result;
1447
	}
1448
1449
	/**
1450
	 * The "old API" format is used e.g. in getMusicDirectory and getAlbumList
1451
	 */
1452
	private function albumToOldApi(Album $album) : array {
1453
		$result = $this->albumCommonApiFields($album);
1454
1455
		$result['parent'] = 'artist-' . $album->getAlbumArtistId();
1456
		$result['title'] = $album->getNameString($this->l10n);
1457
		$result['isDir'] = true;
1458
1459
		return $result;
1460
	}
1461
1462
	/**
1463
	 * The "new API" format is used e.g. in getAlbum and getAlbumList2
1464
	 */
1465
	private function albumToNewApi(Album $album) : array {
1466
		$result = $this->albumCommonApiFields($album);
1467
1468
		$result['artistId'] = 'artist-' . $album->getAlbumArtistId();
1469
		$result['name'] = $album->getNameString($this->l10n);
1470
		$result['songCount'] = $this->trackBusinessLayer->countByAlbum($album->getId());
1471
		$result['duration'] = $this->trackBusinessLayer->totalDurationOfAlbum($album->getId());
1472
1473
		return $result;
1474
	}
1475
1476
	private function albumCommonApiFields(Album $album) : array {
1477
		$genreString = \implode(', ', \array_map(
1478
			fn(Genre $genre) => $genre->getNameString($this->l10n),
1479
			$album->getGenres() ?? []
1480
		));
1481
1482
		return [
1483
			'id' => 'album-' . $album->getId(),
1484
			'artist' => $album->getAlbumArtistNameString($this->l10n),
1485
			'created' => Util::formatZuluDateTime($album->getCreated()),
1486
			'coverArt' => empty($album->getCoverFileId()) ? null : 'album-' . $album->getId(),
1487
			'starred' => Util::formatZuluDateTime($album->getStarred()),
1488
			'userRating' => $album->getRating() ?: null,
1489
			'averageRating' => $album->getRating() ?: null,
1490
			'year' => $album->yearToAPI(),
1491
			'genre' => $genreString ?: null,
1492
			'sortName' => $this->nameWithoutArticle($album->getName()) ?? '', // OpenSubsonic
1493
		];
1494
	}
1495
1496
	/**
1497
	 * @param Track[] $tracks
1498
	 */
1499
	private function tracksToApi(array $tracks) : array {
1500
		$userId = $this->user();
1501
		$musicFolder = $this->librarySettings->getFolder($userId);
1502
		$this->trackBusinessLayer->injectFolderPathsToTracks($tracks, $userId, $musicFolder);
1503
		$this->albumBusinessLayer->injectAlbumsToTracks($tracks, $userId);
1504
		return \array_map(fn($t) => $t->toSubsonicApi($this->l10n, $this->ignoredArticles), $tracks);
1505
	}
1506
1507
	private function trackToApi(Track $track) : array {
1508
		return $this->tracksToApi([$track])[0];
1509
	}
1510
1511
	/**
1512
	 * Common logic for getAlbumList and getAlbumList2
1513
	 * @return Album[]
1514
	 */
1515
	private function albumsForGetAlbumList(
1516
			string $type, ?string $genre, ?int $fromYear, ?int $toYear, int $size, int $offset) : array {
1517
		$size = \min($size, 500); // the API spec limits the maximum amount to 500
1518
		$userId = $this->user();
1519
1520
		$albums = [];
1521
1522
		switch ($type) {
1523
			case 'random':
1524
				$allAlbums = $this->albumBusinessLayer->findAll($userId);
1525
				$indices = $this->random->getIndices(\count($allAlbums), $offset, $size, $userId, 'subsonic_albums');
1526
				$albums = ArrayUtil::multiGet($allAlbums, $indices);
1527
				break;
1528
			case 'starred':
1529
				$albums = $this->albumBusinessLayer->findAllStarred($userId, $size, $offset);
1530
				break;
1531
			case 'alphabeticalByName':
1532
				$albums = $this->albumBusinessLayer->findAll($userId, SortBy::Name, $size, $offset);
1533
				break;
1534
			case 'alphabeticalByArtist':
1535
				$albums = $this->albumBusinessLayer->findAll($userId, SortBy::Parent, $size, $offset);
1536
				break;
1537
			case 'byGenre':
1538
				self::ensureParamHasValue('genre', $genre);
1539
				$albums = $this->findAlbumsByGenre($genre, $size, $offset);
1540
				break;
1541
			case 'byYear':
1542
				self::ensureParamHasValue('fromYear', $fromYear);
1543
				self::ensureParamHasValue('toYear', $toYear);
1544
				$albums = $this->albumBusinessLayer->findAllByYearRange($fromYear, $toYear, $userId, $size, $offset);
1545
				break;
1546
			case 'newest':
1547
				$albums = $this->albumBusinessLayer->findAll($userId, SortBy::Newest, $size, $offset);
1548
				break;
1549
			case 'frequent':
1550
				$albums = $this->albumBusinessLayer->findFrequentPlay($userId, $size, $offset);
1551
				break;
1552
			case 'recent':
1553
				$albums = $this->albumBusinessLayer->findRecentPlay($userId, $size, $offset);
1554
				break;
1555
			case 'highest':
1556
				$albums = $this->albumBusinessLayer->findAllRated($userId, $size, $offset);
1557
				break;
1558
			default:
1559
				$this->logger->debug("Album list type '$type' is not supported");
1560
				break;
1561
		}
1562
1563
		return $albums;
1564
	}
1565
1566
	/**
1567
	 * Given any entity ID like 'track-123' or 'album-2' or 'artist-3' or 'folder-4', return the matching
1568
	 * numeric artist identifier if possible (may be e.g. performer of the track or album, or an artist
1569
	 * with a name matching the folder name)
1570
	 */
1571
	private function getArtistIdFromEntityId(string $entityId) : ?int {
1572
		list($type, $id) = self::parseEntityId($entityId);
1573
		$userId = $this->user();
1574
1575
		switch ($type) {
1576
			case 'artist':
1577
				return $id;
1578
			case 'album':
1579
				return $this->albumBusinessLayer->find($id, $userId)->getAlbumArtistId();
1580
			case 'track':
1581
				return $this->trackBusinessLayer->find($id, $userId)->getArtistId();
1582
			case 'folder':
1583
				$folder = $this->librarySettings->getFolder($userId)->getById($id)[0] ?? null;
1584
				if ($folder !== null) {
1585
					$artist = $this->artistBusinessLayer->findAllByName($folder->getName(), $userId)[0] ?? null;
1586
					if ($artist !== null) {
1587
						return $artist->getId();
1588
					}
1589
				}
1590
				break;
1591
		}
1592
1593
		return null;
1594
	}
1595
1596
	/**
1597
	 * Common logic for getArtistInfo and getArtistInfo2
1598
	 */
1599
	private function doGetArtistInfo(string $rootName, string $id, bool $includeNotPresent) : Response {
1600
		$content = [];
1601
1602
		$userId = $this->user();
1603
		$artistId = $this->getArtistIdFromEntityId($id);
1604
		if ($artistId !== null) {
1605
			$info = $this->lastfmService->getArtistInfo($artistId, $userId);
1606
1607
			if (isset($info['artist'])) {
1608
				$content = [
1609
					'biography' => $info['artist']['bio']['summary'],
1610
					'lastFmUrl' => $info['artist']['url'],
1611
					'musicBrainzId' => $info['artist']['mbid'] ?? null
1612
				];
1613
1614
				$similarArtists = $this->lastfmService->getSimilarArtists($artistId, $userId, $includeNotPresent);
1615
				$content['similarArtist'] = \array_map([$this, 'artistToApi'], $similarArtists);
1616
			}
1617
1618
			$artist = $this->artistBusinessLayer->find($artistId, $userId);
1619
			if ($artist->getCoverFileId() !== null) {
1620
				$content['largeImageUrl'] = [$this->artistImageUrl($artistId)];
1621
			}
1622
		}
1623
1624
		// This method is unusual in how it uses non-attribute elements in the response. On the other hand,
1625
		// all the details of the <similarArtist> elements are rendered as attributes. List those separately.
1626
		$attributeKeys = ['name', 'id', 'albumCount', 'coverArt', 'artistImageUrl', 'starred'];
1627
1628
		return $this->subsonicResponse([$rootName => $content], $attributeKeys);
1629
	}
1630
1631
	/**
1632
	 * Given any entity ID like 'track-123' or 'album-2' or 'folder-4', return the matching numeric
1633
	 * album identifier if possible (may be e.g. host album of the track or album with a name
1634
	 * matching the folder name)
1635
	 */
1636
	private function getAlbumIdFromEntityId(string $entityId) : ?int {
1637
		list($type, $id) = self::parseEntityId($entityId);
1638
		$userId = $this->user();
1639
1640
		switch ($type) {
1641
			case 'album':
1642
				return $id;
1643
			case 'track':
1644
				return $this->trackBusinessLayer->find($id, $userId)->getAlbumId();
1645
			case 'folder':
1646
				$folder = $this->librarySettings->getFolder($userId)->getById($id)[0] ?? null;
1647
				if ($folder !== null) {
1648
					$album = $this->albumBusinessLayer->findAllByName($folder->getName(), $userId)[0] ?? null;
1649
					if ($album !== null) {
1650
						return $album->getId();
1651
					}
1652
				}
1653
				break;
1654
		}
1655
1656
		return null;
1657
	}
1658
1659
	/**
1660
	 * Common logic for getAlbumInfo and getAlbumInfo2
1661
	 */
1662
	private function doGetAlbumInfo(string $id) : Response {
1663
		$albumId = $this->getAlbumIdFromEntityId($id);
1664
		if ($albumId === null) {
1665
			throw new SubsonicException("Unexpected ID format: $id", 0);
1666
		}
1667
1668
		$info = $this->lastfmService->getAlbumInfo($albumId, $this->user());
1669
1670
		if (isset($info['album'])) {
1671
			$content = [
1672
				'notes' => $info['album']['wiki']['summary'] ?? null,
1673
				'lastFmUrl' => $info['album']['url'],
1674
				'musicBrainzId' => $info['album']['mbid'] ?? null
1675
			];
1676
1677
			foreach ($info['album']['image'] ?? [] as $imageInfo) {
1678
				if (!empty($imageInfo['size'])) {
1679
					$content[$imageInfo['size'] . 'ImageUrl'] = $imageInfo['#text'];
1680
				}
1681
			}
1682
		} else {
1683
			$content = new \stdClass;
1684
		}
1685
1686
		// This method is unusual in how it uses non-attribute elements in the response.
1687
		return $this->subsonicResponse(['albumInfo' => $content], []);
1688
	}
1689
1690
	/**
1691
	 * Common logic for getSimilarSongs and getSimilarSongs2
1692
	 */
1693
	private function doGetSimilarSongs(string $rootName, string $id, int $count) : array {
1694
		$userId = $this->user();
1695
1696
		if (StringUtil::startsWith($id, 'artist')) {
1697
			$artistId = self::ripIdPrefix($id);
1698
		} elseif (StringUtil::startsWith($id, 'album')) {
1699
			$albumId = self::ripIdPrefix($id);
1700
			$artistId = $this->albumBusinessLayer->find($albumId, $userId)->getAlbumArtistId();
1701
		} elseif (StringUtil::startsWith($id, 'track')) {
1702
			$trackId = self::ripIdPrefix($id);
1703
			$artistId = $this->trackBusinessLayer->find($trackId, $userId)->getArtistId();
1704
		} else {
1705
			throw new SubsonicException("Id $id has a type not supported on getSimilarSongs", 0);
1706
		}
1707
1708
		$artists = $this->lastfmService->getSimilarArtists($artistId, $userId);
1709
		$artists[] = $this->artistBusinessLayer->find($artistId, $userId);
1710
1711
		// Get all songs by the found artists
1712
		$songs = [];
1713
		foreach ($artists as $artist) {
1714
			$songs = \array_merge($songs, $this->trackBusinessLayer->findAllByArtist($artist->getId(), $userId));
1715
		}
1716
1717
		// Randomly select the desired number of songs
1718
		$songs = $this->random->pickItems($songs, $count);
1719
1720
		return [$rootName => [
1721
			'song' => $this->tracksToApi($songs)
1722
		]];
1723
	}
1724
1725
	/**
1726
	 * Common logic for search2 and search3
1727
	 * @return array with keys 'artists', 'albums', and 'tracks'
1728
	 */
1729
	private function doSearch(string $query, int $artistCount, int $artistOffset,
1730
			int $albumCount, int $albumOffset, int $songCount, int $songOffset) : array {
1731
1732
		$userId = $this->user();
1733
1734
		// The searches support '*' as a wildcard. Convert those to the SQL wildcard '%' as that's what the business layer searches support.
1735
		$query = \str_replace('*', '%', $query);
1736
1737
		return [
1738
			'artists' => $this->artistBusinessLayer->findAllByName($query, $userId, MatchMode::Substring, $artistCount, $artistOffset),
1739
			'albums' => $this->albumBusinessLayer->findAllByNameRecursive($query, $userId, $albumCount, $albumOffset),
1740
			'tracks' => $this->trackBusinessLayer->findAllByNameRecursive($query, $userId, $songCount, $songOffset)
1741
		];
1742
	}
1743
1744
	/**
1745
	 * Common logic for getStarred and getStarred2
1746
	 */
1747
	private function doGetStarred() : array {
1748
		$userId = $this->user();
1749
		return [
1750
			'artists' => $this->artistBusinessLayer->findAllStarred($userId),
1751
			'albums' => $this->albumBusinessLayer->findAllStarred($userId),
1752
			'tracks' => $this->trackBusinessLayer->findAllStarred($userId)
1753
		];
1754
	}
1755
1756
	/**
1757
	 * Common response building logic for search2, search3, getStarred, and getStarred2
1758
	 * @param string $title Name of the main node in the response message
1759
	 * @param array $results Search results with keys 'artists', 'albums', and 'tracks'
1760
	 * @param bool $useNewApi Set to true for search3 and getStarred2. There is a difference
1761
	 *                        in the formatting of the album nodes.
1762
	 */
1763
	private function searchResponse(string $title, array $results, bool $useNewApi) : array {
1764
		$albumMapFunc = $useNewApi ? 'albumToNewApi' : 'albumToOldApi';
1765
1766
		return [$title => [
1767
			'artist' => \array_map([$this, 'artistToApi'], $results['artists']),
1768
			'album' => \array_map([$this, $albumMapFunc], $results['albums']),
1769
			'song' => $this->tracksToApi($results['tracks'])
1770
		]];
1771
	}
1772
1773
	/**
1774
	 * Find tracks by genre name
1775
	 * @return Track[]
1776
	 */
1777
	private function findTracksByGenre(string $genreName, ?int $limit=null, ?int $offset=null) : array {
1778
		$genre = $this->findGenreByName($genreName);
1779
1780
		if ($genre) {
1781
			return $this->trackBusinessLayer->findAllByGenre($genre->getId(), $this->user(), $limit, $offset);
1782
		} else {
1783
			return [];
1784
		}
1785
	}
1786
1787
	/**
1788
	 * Find albums by genre name
1789
	 * @return Album[]
1790
	 */
1791
	private function findAlbumsByGenre(string $genreName, ?int $limit=null, ?int $offset=null) : array {
1792
		$genre = $this->findGenreByName($genreName);
1793
1794
		if ($genre) {
1795
			return $this->albumBusinessLayer->findAllByGenre($genre->getId(), $this->user(), $limit, $offset);
1796
		} else {
1797
			return [];
1798
		}
1799
	}
1800
1801
	private function findGenreByName(string $name) : ?Genre {
1802
		$genreArr = $this->genreBusinessLayer->findAllByName($name, $this->user());
1803
		if (\count($genreArr) == 0 && $name == Genre::unknownNameString($this->l10n)) {
1804
			$genreArr = $this->genreBusinessLayer->findAllByName('', $this->user());
1805
		}
1806
		return \count($genreArr) ? $genreArr[0] : null;
1807
	}
1808
1809
	private function artistImageUrl(int $id) : string {
1810
		\assert($this->keyId !== null, 'function should not get called without authenticated user');
1811
		$token = $this->imageService->getToken('artist', $id, $this->keyId);
1812
		return $this->urlGenerator->linkToRouteAbsolute('music.ampacheImage.image',
1813
			['object_type' => 'artist', 'object_id' => $id, 'token' => $token, 'size' => CoverService::DO_NOT_CROP_OR_SCALE]);
1814
	}
1815
1816
	/**
1817
	 * Given a prefixed ID like 'artist-123' or 'track-45', return the string part and the numeric part.
1818
	 * @throws SubsonicException if the \a $id doesn't follow the expected pattern
1819
	 */
1820
	private static function parseEntityId(string $id) : array {
1821
		$parts = \explode('-', $id);
1822
		if (\count($parts) !== 2) {
1823
			throw new SubsonicException("Unexpected ID format: $id", 0);
1824
		}
1825
		$parts[1] = (int)$parts[1];
1826
		return $parts;
1827
	}
1828
1829
	/**
1830
	 * Given a prefixed ID like 'artist-123' or 'track-45', return just the numeric part.
1831
	 */
1832
	private static function ripIdPrefix(string $id) : int {
1833
		return self::parseEntityId($id)[1];
1834
	}
1835
1836
	/**
1837
	 * @param bool|string[] $useAttributes
1838
	 */
1839
	private function subsonicResponse(array $content, /*mixed*/ $useAttributes=true, string $status = 'ok') : Response {
1840
		$content['status'] = $status;
1841
		$content['version'] = self::API_VERSION;
1842
		$content['type'] = AppInfo::getFullName();
1843
		$content['serverVersion'] = AppInfo::getVersion();
1844
		$content['openSubsonic'] = true;
1845
		$responseData = ['subsonic-response' => ArrayUtil::rejectRecursive($content, 'is_null')];
1846
1847
		if ($this->format == 'json') {
1848
			$response = new JSONResponse($responseData);
1849
		} elseif ($this->format == 'jsonp') {
1850
			$responseData = \json_encode($responseData);
1851
			$response = new DataDisplayResponse("{$this->callback}($responseData);");
1852
			$response->addHeader('Content-Type', 'text/javascript; charset=UTF-8');
1853
		} else {
1854
			if (\is_array($useAttributes)) {
1855
				$useAttributes = \array_merge($useAttributes, ['status', 'version', 'type', 'serverVersion', 'xmlns']);
1856
			}
1857
			$responseData['subsonic-response']['xmlns'] = 'http://subsonic.org/restapi';
1858
			$response = new XmlResponse($responseData, $useAttributes);
1859
		}
1860
1861
		return $response;
1862
	}
1863
1864
	public function subsonicErrorResponse(int $errorCode, string $errorMessage) : Response {
1865
		return $this->subsonicResponse([
1866
				'error' => [
1867
					'code' => $errorCode,
1868
					'message' => $errorMessage
1869
				]
1870
			], true, 'failed');
1871
	}
1872
}
1873