SubsonicController::__construct()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 52
Code Lines 27

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 1
eloc 27
nc 1
nop 24
dl 0
loc 52
rs 9.488
c 2
b 0
f 0

How to fix   Long Method    Many Parameters   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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