Passed
Push — master ( 83d572...17c85a )
by Pauli
03:35
created

SubsonicController::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 50
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 1
eloc 26
c 2
b 0
f 0
nc 1
nop 23
dl 0
loc 50
rs 9.504

How to fix   Many Parameters   

Many Parameters

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

There are several approaches to avoid long parameter lists:

1
<?php declare(strict_types=1);
2
3
/**
4
 * ownCloud - Music app
5
 *
6
 * This file is licensed under the Affero General Public License version 3 or
7
 * later. See the COPYING file.
8
 *
9
 * @author Pauli Järvinen <[email protected]>
10
 * @copyright Pauli Järvinen 2019 - 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() : array {
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 [];
1080
		}
1081
1082
		$parsedEntries = \array_map([self::class, 'parseEntityId'], $playQueue['entry']);
1083
1084
		$typeHandlers = [
1085
			[
1086
				'track',
1087
				[$this->trackBusinessLayer, 'findById'],
1088
				[$this, 'trackstoApi']
1089
			],
1090
			[
1091
				'podcast_episode',
1092
				[$this->podcastEpisodeBusinessLayer, 'findById'],
1093
				[$this, 'podcastEpisodestoApi']
1094
			]
1095
		];
1096
1097
		/** @var array{'track': Track[], 'podcast_episode': PodcastEpisode[]} $apiEntries */
1098
		$apiEntries = array_merge([], ...array_map(
1099
			function ($handlers) use ($parsedEntries) {
1100
				[$type, $lookupFn, $toApiFn] = $handlers;
1101
				$typeEntryIds = \array_map(
1102
					fn ($entry) => $entry[1],
1103
					\array_filter($parsedEntries, fn ($parsedEntry) => $parsedEntry[0] === $type)
1104
				);
1105
1106
				$entryInstances = $lookupFn($typeEntryIds, $this->user());
1107
1108
				return [
1109
					$type => $toApiFn(ArrayUtil::createIdLookupTable($entryInstances))
1110
				];
1111
			},
1112
			$typeHandlers
1113
		));
1114
1115
		$playQueue['entry'] = \array_filter(\array_map(
1116
			function ($parsedEntry) use ($apiEntries) {
1117
				[$type, $id] = $parsedEntry;
1118
				return $apiEntries[$type][$id] ?? false;
1119
			},
1120
			$parsedEntries
1121
		));
1122
1123
		return ['playQueue' => $playQueue];
1124
	}
1125
1126
	/**
1127
	 * @SubsonicAPI
1128
	 */
1129
	protected function savePlayQueue(array $id, string $c, ?string $current = null, ?int $position = null) : array {
1130
		$changedDateTime = new \DateTime();
1131
		$playQueue = array_filter([
1132
			'entry' => array_filter(
1133
				$id,
1134
				fn (string $entityId) => in_array(self::parseEntityId($entityId)[0], ['track', 'podcast_episode'])
1135
			),
1136
			'changedBy' => $c,
1137
			'position' => $position,
1138
			'current' => $current,
1139
			/** @see Util::formatZuluDateTime (if only we could pass a datetime!) */
1140
			'changed' => $changedDateTime->format('Y-m-d\TH:i:s.v\Z'),
1141
			'username' => $this->user()
1142
		], fn ($val) => $val !== null);
1143
1144
		$playQueueJson = json_encode($playQueue, \JSON_THROW_ON_ERROR);
1145
		$this->configManager->setUserValue($this->userId, $this->appName, 'play_queue', $playQueueJson);
1146
1147
		return [];
1148
	}
1149
1150
	/**
1151
	 * @SubsonicAPI
1152
	 */
1153
	protected function getScanStatus() : array {
1154
		return ['scanStatus' => [
1155
			'scanning' => false,
1156
			'count' => $this->trackBusinessLayer->count($this->user())
1157
		]];
1158
	}
1159
1160
	/**
1161
	 * @SubsonicAPI
1162
	 */
1163
	protected function getNowPlaying() : array {
1164
		// TODO: not supported yet
1165
		return ['nowPlaying' => ['entry' => []]];
1166
	}
1167
1168
	/**
1169
	 * @SubsonicAPI
1170
	 */
1171
	protected function getOpenSubsonicExtensions() : array {
1172
		return ['openSubsonicExtensions' => [
1173
			[ 'name' => 'apiKeyAuthentication', 'versions' => [1] ],
1174
			[ 'name' => 'formPost', 'versions' => [1] ],
1175
			[ 'name' => 'getPodcastEpisode', 'versions' => [1] ],
1176
			[ 'name' => 'songLyrics', 'versions' => [1] ],
1177
		]];
1178
	}
1179
1180
	/* -------------------------------------------------------------------------
1181
	 * Helper methods
1182
	 * -------------------------------------------------------------------------
1183
	 */
1184
1185
	/**
1186
	 * @param string|int|null $paramValue
1187
	 */
1188
	private static function ensureParamHasValue(string $paramName, /*mixed*/ $paramValue) : void {
1189
		if ($paramValue === null || $paramValue === '') {
1190
			throw new SubsonicException("Required parameter '$paramName' missing", 10);
1191
		}
1192
	}
1193
1194
	private static function parseBookmarkIdParam(string $id) : array {
1195
		list($typeName, $entityId) = self::parseEntityId($id);
1196
1197
		if ($typeName === 'track') {
1198
			$type = Bookmark::TYPE_TRACK;
1199
		} elseif ($typeName === 'podcast_episode') {
1200
			$type = Bookmark::TYPE_PODCAST_EPISODE;
1201
		} else {
1202
			throw new SubsonicException("Unsupported ID format $id", 0);
1203
		}
1204
1205
		return [$type, $entityId];
1206
	}
1207
1208
	/**
1209
	 * Parse parameters used in the `star` and `unstar` API methods
1210
	 */
1211
	private static function parseStarringParameters(array $ids, array $albumIds, array $artistIds) : array {
1212
		// album IDs from newer clients
1213
		$albumIds = \array_map('self::ripIdPrefix', $albumIds);
1214
1215
		// artist IDs from newer clients
1216
		$artistIds = \array_map('self::ripIdPrefix', $artistIds);
1217
1218
		// Song IDs from newer clients and song/folder/album/artist IDs from older clients are all packed in $ids.
1219
		// Also podcast IDs may come there; that is not documented part of the API but at least DSub does that.
1220
1221
		$trackIds = [];
1222
		$channelIds = [];
1223
		$episodeIds = [];
1224
1225
		foreach ($ids as $prefixedId) {
1226
			list($type, $id) = self::parseEntityId($prefixedId);
1227
1228
			if ($type == 'track') {
1229
				$trackIds[] = $id;
1230
			} elseif ($type == 'album') {
1231
				$albumIds[] = $id;
1232
			} elseif ($type == 'artist') {
1233
				$artistIds[] = $id;
1234
			} elseif ($type == 'podcast_channel') {
1235
				$channelIds[] = $id;
1236
			} elseif ($type == 'podcast_episode') {
1237
				$episodeIds[] = $id;
1238
			} elseif ($type == 'folder') {
1239
				throw new SubsonicException('Starring folders is not supported', 0);
1240
			} else {
1241
				throw new SubsonicException("Unexpected ID format: $prefixedId", 0);
1242
			}
1243
		}
1244
1245
		return [
1246
			'tracks' => $trackIds,
1247
			'albums' => $albumIds,
1248
			'artists' => $artistIds,
1249
			'podcast_channels' => $channelIds,
1250
			'podcast_episodes' => $episodeIds
1251
		];
1252
	}
1253
1254
	private function user() : string {
1255
		if ($this->userId === null) {
1256
			throw new SubsonicException('User authentication required', 10);
1257
		}
1258
		return $this->userId;
1259
	}
1260
1261
	private function getFilesystemNode(int $id) : Node {
1262
		$rootFolder = $this->librarySettings->getFolder($this->user());
1263
		$nodes = $rootFolder->getById($id);
1264
1265
		if (\count($nodes) != 1) {
1266
			throw new SubsonicException('file not found', 70);
1267
		}
1268
1269
		return $nodes[0];
1270
	}
1271
1272
	private function nameWithoutArticle(?string $name) : ?string {
1273
		return StringUtil::splitPrefixAndBasename($name, $this->ignoredArticles)['basename'];
1274
	}
1275
1276
	private static function getIndexingChar(?string $name) : string {
1277
		// For unknown artists, use '?'
1278
		$char = '?';
1279
1280
		if (!empty($name)) {
1281
			$char = \mb_convert_case(\mb_substr($name, 0, 1), MB_CASE_UPPER);
1282
		}
1283
		// Bundle all numeric characters together
1284
		if (\is_numeric($char)) {
1285
			$char = '#';
1286
		}
1287
1288
		return $char;
1289
	}
1290
1291
	private function getSubFoldersAndTracks(Folder $folder) : array {
1292
		$nodes = $folder->getDirectoryListing();
1293
		$subFolders = \array_filter($nodes, fn($n) =>
1294
			($n instanceof Folder) && $this->librarySettings->pathBelongsToMusicLibrary($n->getPath(), $this->user())
1295
		);
1296
1297
		$tracks = $this->trackBusinessLayer->findAllByFolder($folder->getId(), $this->user());
1298
1299
		return [$subFolders, $tracks];
1300
	}
1301
1302
	private function getIndexesForFolders() : array {
1303
		$rootFolder = $this->librarySettings->getFolder($this->user());
1304
1305
		list($subFolders, $tracks) = $this->getSubFoldersAndTracks($rootFolder);
1306
1307
		$indexes = [];
1308
		foreach ($subFolders as $folder) {
1309
			$sortName = $this->nameWithoutArticle($folder->getName());
1310
			$indexes[self::getIndexingChar($sortName)][] = [
1311
				'sortName' => $sortName,
1312
				'artist' => [
1313
					'name' => $folder->getName(),
1314
					'id' => 'folder-' . $folder->getId()
1315
				]
1316
			];
1317
		}
1318
		\ksort($indexes, SORT_LOCALE_STRING);
1319
1320
		$folders = [];
1321
		foreach ($indexes as $indexChar => $bucketArtists) {
1322
			ArrayUtil::sortByColumn($bucketArtists, 'sortName');
1323
			$folders[] = ['name' => $indexChar, 'artist' => \array_column($bucketArtists, 'artist')];
1324
		}
1325
1326
		return ['indexes' => [
1327
			'ignoredArticles' => \implode(' ', $this->ignoredArticles),
1328
			'index' => $folders,
1329
			'child' => $this->tracksToApi($tracks)
1330
		]];
1331
	}
1332
1333
	private function getMusicDirectoryForFolder(string $id) : array {
1334
		$folderId = self::ripIdPrefix($id); // get rid of 'folder-' prefix
1335
		$folder = $this->getFilesystemNode($folderId);
1336
1337
		if (!($folder instanceof Folder)) {
1338
			throw new SubsonicException("$id is not a valid folder", 70);
1339
		}
1340
1341
		list($subFolders, $tracks) = $this->getSubFoldersAndTracks($folder);
1342
1343
		$children = \array_merge(
1344
			\array_map([$this, 'folderToApi'], $subFolders),
1345
			$this->tracksToApi($tracks)
1346
		);
1347
1348
		$content = [
1349
			'directory' => [
1350
				'id' => $id,
1351
				'name' => $folder->getName(),
1352
				'child' => $children
1353
			]
1354
		];
1355
1356
		// Parent folder ID is included if and only if the parent folder is not the top level
1357
		$rootFolderId = $this->librarySettings->getFolder($this->user())->getId();
1358
		$parentFolderId = $folder->getParent()->getId();
1359
		if ($rootFolderId != $parentFolderId) {
1360
			$content['parent'] = 'folder-' . $parentFolderId;
1361
		}
1362
1363
		return $content;
1364
	}
1365
1366
	private function getIndexesForArtists(string $rootElementName = 'indexes') : array {
1367
		$artists = $this->artistBusinessLayer->findAllHavingAlbums($this->user(), SortBy::Name);
1368
1369
		$indexes = [];
1370
		foreach ($artists as $artist) {
1371
			$sortName = $this->nameWithoutArticle($artist->getName());
1372
			$indexes[self::getIndexingChar($sortName)][] = ['sortName' => $sortName, 'artist' => $this->artistToApi($artist)];
1373
		}
1374
		\ksort($indexes, SORT_LOCALE_STRING);
1375
1376
		$result = [];
1377
		foreach ($indexes as $indexChar => $bucketArtists) {
1378
			ArrayUtil::sortByColumn($bucketArtists, 'sortName');
1379
			$result[] = ['name' => $indexChar, 'artist' => \array_column($bucketArtists, 'artist')];
1380
		}
1381
1382
		return [$rootElementName => [
1383
			'ignoredArticles' => \implode(' ', $this->ignoredArticles),
1384
			'index' => $result
1385
		]];
1386
	}
1387
1388
	private function getMusicDirectoryForArtist(string $id) : array {
1389
		$artistId = self::ripIdPrefix($id); // get rid of 'artist-' prefix
1390
1391
		$artist = $this->artistBusinessLayer->find($artistId, $this->user());
1392
		$albums = $this->albumBusinessLayer->findAllByArtist($artistId, $this->user());
1393
1394
		return [
1395
			'directory' => [
1396
				'id' => $id,
1397
				'name' => $artist->getNameString($this->l10n),
1398
				'child' => \array_map([$this, 'albumToOldApi'], $albums)
1399
			]
1400
		];
1401
	}
1402
1403
	private function getMusicDirectoryForAlbum(string $id) : array {
1404
		$albumId = self::ripIdPrefix($id); // get rid of 'album-' prefix
1405
1406
		$album = $this->albumBusinessLayer->find($albumId, $this->user());
1407
		$albumName = $album->getNameString($this->l10n);
1408
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $this->user());
1409
1410
		return [
1411
			'directory' => [
1412
				'id' => $id,
1413
				'parent' => 'artist-' . $album->getAlbumArtistId(),
1414
				'name' => $albumName,
1415
				'child' => $this->tracksToApi($tracks)
1416
			]
1417
		];
1418
	}
1419
1420
	private function getMusicDirectoryForPodcastChannel(string $id) : array {
1421
		$channelId = self::ripIdPrefix($id); // get rid of 'podcast_channel-' prefix
1422
		$channel = $this->podcastService->getChannel($channelId, $this->user(), /*$includeEpisodes=*/ true);
1423
1424
		if ($channel === null) {
1425
			throw new SubsonicException("Podcast channel $channelId not found", 0);
1426
		}
1427
1428
		return [
1429
			'directory' => [
1430
				'id' => $id,
1431
				'name' => $channel->getTitle(),
1432
				'child' => \array_map(fn($e) => $e->toSubsonicApi(), $channel->getEpisodes() ?? [])
1433
			]
1434
		];
1435
	}
1436
1437
	private function folderToApi(Folder $folder) : array {
1438
		return [
1439
			'id' => 'folder-' . $folder->getId(),
1440
			'title' => $folder->getName(),
1441
			'isDir' => true
1442
		];
1443
	}
1444
1445
	private function artistToApi(Artist $artist) : array {
1446
		$id = $artist->getId();
1447
		$result = [
1448
			'name' => $artist->getNameString($this->l10n),
1449
			'id' => $id ? ('artist-' . $id) : '-1', // getArtistInfo may show artists without ID
1450
			'albumCount' => $id ? $this->albumBusinessLayer->countByArtist($id) : 0,
1451
			'starred' => Util::formatZuluDateTime($artist->getStarred()),
1452
			'userRating' => $artist->getRating() ?: null,
1453
			'averageRating' => $artist->getRating() ?: null,
1454
			'sortName' => $this->nameWithoutArticle($artist->getName()) ?? '', // OpenSubsonic
1455
		];
1456
1457
		if (!empty($artist->getCoverFileId())) {
1458
			$result['coverArt'] = $result['id'];
1459
			$result['artistImageUrl'] = $this->artistImageUrl($id);
1460
		}
1461
1462
		return $result;
1463
	}
1464
1465
	/**
1466
	 * The "old API" format is used e.g. in getMusicDirectory and getAlbumList
1467
	 */
1468
	private function albumToOldApi(Album $album) : array {
1469
		$result = $this->albumCommonApiFields($album);
1470
1471
		$result['parent'] = 'artist-' . $album->getAlbumArtistId();
1472
		$result['title'] = $album->getNameString($this->l10n);
1473
		$result['isDir'] = true;
1474
1475
		return $result;
1476
	}
1477
1478
	/**
1479
	 * The "new API" format is used e.g. in getAlbum and getAlbumList2
1480
	 */
1481
	private function albumToNewApi(Album $album) : array {
1482
		$result = $this->albumCommonApiFields($album);
1483
1484
		$result['artistId'] = 'artist-' . $album->getAlbumArtistId();
1485
		$result['name'] = $album->getNameString($this->l10n);
1486
		$result['songCount'] = $this->trackBusinessLayer->countByAlbum($album->getId());
1487
		$result['duration'] = $this->trackBusinessLayer->totalDurationOfAlbum($album->getId());
1488
1489
		return $result;
1490
	}
1491
1492
	private function albumCommonApiFields(Album $album) : array {
1493
		$genreString = \implode(', ', \array_map(
1494
			fn(Genre $genre) => $genre->getNameString($this->l10n),
1495
			$album->getGenres() ?? []
1496
		));
1497
1498
		return [
1499
			'id' => 'album-' . $album->getId(),
1500
			'artist' => $album->getAlbumArtistNameString($this->l10n),
1501
			'created' => Util::formatZuluDateTime($album->getCreated()),
1502
			'coverArt' => empty($album->getCoverFileId()) ? null : 'album-' . $album->getId(),
1503
			'starred' => Util::formatZuluDateTime($album->getStarred()),
1504
			'userRating' => $album->getRating() ?: null,
1505
			'averageRating' => $album->getRating() ?: null,
1506
			'year' => $album->yearToAPI(),
1507
			'genre' => $genreString ?: null,
1508
			'sortName' => $this->nameWithoutArticle($album->getName()) ?? '', // OpenSubsonic
1509
		];
1510
	}
1511
1512
	/**
1513
	 * @param Track[] $tracks
1514
	 */
1515
	private function tracksToApi(array $tracks) : array {
1516
		$userId = $this->user();
1517
		$musicFolder = $this->librarySettings->getFolder($userId);
1518
		$this->trackBusinessLayer->injectFolderPathsToTracks($tracks, $userId, $musicFolder);
1519
		$this->albumBusinessLayer->injectAlbumsToTracks($tracks, $userId);
1520
		return \array_map(fn($t) => $t->toSubsonicApi($this->l10n, $this->ignoredArticles), $tracks);
1521
	}
1522
1523
	private function trackToApi(Track $track) : array {
1524
		return $this->tracksToApi([$track])[0];
1525
	}
1526
1527
	/**
1528
	 * @param PodcastEpisode[] $episodes
1529
	 */
1530
	private function podcastEpisodestoApi(array $episodes) : array {
1531
		return \array_map(fn(PodcastEpisode $p) => $p->toSubsonicApi(), $episodes);
1532
	}
1533
1534
	/**
1535
	 * Common logic for getAlbumList and getAlbumList2
1536
	 * @return Album[]
1537
	 */
1538
	private function albumsForGetAlbumList(
1539
			string $type, ?string $genre, ?int $fromYear, ?int $toYear, int $size, int $offset) : array {
1540
		$size = \min($size, 500); // the API spec limits the maximum amount to 500
1541
		$userId = $this->user();
1542
1543
		$albums = [];
1544
1545
		switch ($type) {
1546
			case 'random':
1547
				$allAlbums = $this->albumBusinessLayer->findAll($userId);
1548
				$indices = $this->random->getIndices(\count($allAlbums), $offset, $size, $userId, 'subsonic_albums');
1549
				$albums = ArrayUtil::multiGet($allAlbums, $indices);
1550
				break;
1551
			case 'starred':
1552
				$albums = $this->albumBusinessLayer->findAllStarred($userId, $size, $offset);
1553
				break;
1554
			case 'alphabeticalByName':
1555
				$albums = $this->albumBusinessLayer->findAll($userId, SortBy::Name, $size, $offset);
1556
				break;
1557
			case 'alphabeticalByArtist':
1558
				$albums = $this->albumBusinessLayer->findAll($userId, SortBy::Parent, $size, $offset);
1559
				break;
1560
			case 'byGenre':
1561
				self::ensureParamHasValue('genre', $genre);
1562
				$albums = $this->findAlbumsByGenre($genre, $size, $offset);
1563
				break;
1564
			case 'byYear':
1565
				self::ensureParamHasValue('fromYear', $fromYear);
1566
				self::ensureParamHasValue('toYear', $toYear);
1567
				$albums = $this->albumBusinessLayer->findAllByYearRange($fromYear, $toYear, $userId, $size, $offset);
1568
				break;
1569
			case 'newest':
1570
				$albums = $this->albumBusinessLayer->findAll($userId, SortBy::Newest, $size, $offset);
1571
				break;
1572
			case 'frequent':
1573
				$albums = $this->albumBusinessLayer->findFrequentPlay($userId, $size, $offset);
1574
				break;
1575
			case 'recent':
1576
				$albums = $this->albumBusinessLayer->findRecentPlay($userId, $size, $offset);
1577
				break;
1578
			case 'highest':
1579
				$albums = $this->albumBusinessLayer->findAllRated($userId, $size, $offset);
1580
				break;
1581
			default:
1582
				$this->logger->debug("Album list type '$type' is not supported");
1583
				break;
1584
		}
1585
1586
		return $albums;
1587
	}
1588
1589
	/**
1590
	 * Given any entity ID like 'track-123' or 'album-2' or 'artist-3' or 'folder-4', return the matching
1591
	 * numeric artist identifier if possible (may be e.g. performer of the track or album, or an artist
1592
	 * with a name matching the folder name)
1593
	 */
1594
	private function getArtistIdFromEntityId(string $entityId) : ?int {
1595
		list($type, $id) = self::parseEntityId($entityId);
1596
		$userId = $this->user();
1597
1598
		switch ($type) {
1599
			case 'artist':
1600
				return $id;
1601
			case 'album':
1602
				return $this->albumBusinessLayer->find($id, $userId)->getAlbumArtistId();
1603
			case 'track':
1604
				return $this->trackBusinessLayer->find($id, $userId)->getArtistId();
1605
			case 'folder':
1606
				$folder = $this->librarySettings->getFolder($userId)->getById($id)[0] ?? null;
1607
				if ($folder !== null) {
1608
					$artist = $this->artistBusinessLayer->findAllByName($folder->getName(), $userId)[0] ?? null;
1609
					if ($artist !== null) {
1610
						return $artist->getId();
1611
					}
1612
				}
1613
				break;
1614
		}
1615
1616
		return null;
1617
	}
1618
1619
	/**
1620
	 * Common logic for getArtistInfo and getArtistInfo2
1621
	 */
1622
	private function doGetArtistInfo(string $rootName, string $id, bool $includeNotPresent) : Response {
1623
		$content = [];
1624
1625
		$userId = $this->user();
1626
		$artistId = $this->getArtistIdFromEntityId($id);
1627
		if ($artistId !== null) {
1628
			$info = $this->lastfmService->getArtistInfo($artistId, $userId);
1629
1630
			if (isset($info['artist'])) {
1631
				$content = [
1632
					'biography' => $info['artist']['bio']['summary'],
1633
					'lastFmUrl' => $info['artist']['url'],
1634
					'musicBrainzId' => $info['artist']['mbid'] ?? null
1635
				];
1636
1637
				$similarArtists = $this->lastfmService->getSimilarArtists($artistId, $userId, $includeNotPresent);
1638
				$content['similarArtist'] = \array_map([$this, 'artistToApi'], $similarArtists);
1639
			}
1640
1641
			$artist = $this->artistBusinessLayer->find($artistId, $userId);
1642
			if ($artist->getCoverFileId() !== null) {
1643
				$content['largeImageUrl'] = [$this->artistImageUrl($artistId)];
1644
			}
1645
		}
1646
1647
		// This method is unusual in how it uses non-attribute elements in the response. On the other hand,
1648
		// all the details of the <similarArtist> elements are rendered as attributes. List those separately.
1649
		$attributeKeys = ['name', 'id', 'albumCount', 'coverArt', 'artistImageUrl', 'starred'];
1650
1651
		return $this->subsonicResponse([$rootName => $content], $attributeKeys);
1652
	}
1653
1654
	/**
1655
	 * Given any entity ID like 'track-123' or 'album-2' or 'folder-4', return the matching numeric
1656
	 * album identifier if possible (may be e.g. host album of the track or album with a name
1657
	 * matching the folder name)
1658
	 */
1659
	private function getAlbumIdFromEntityId(string $entityId) : ?int {
1660
		list($type, $id) = self::parseEntityId($entityId);
1661
		$userId = $this->user();
1662
1663
		switch ($type) {
1664
			case 'album':
1665
				return $id;
1666
			case 'track':
1667
				return $this->trackBusinessLayer->find($id, $userId)->getAlbumId();
1668
			case 'folder':
1669
				$folder = $this->librarySettings->getFolder($userId)->getById($id)[0] ?? null;
1670
				if ($folder !== null) {
1671
					$album = $this->albumBusinessLayer->findAllByName($folder->getName(), $userId)[0] ?? null;
1672
					if ($album !== null) {
1673
						return $album->getId();
1674
					}
1675
				}
1676
				break;
1677
		}
1678
1679
		return null;
1680
	}
1681
1682
	/**
1683
	 * Common logic for getAlbumInfo and getAlbumInfo2
1684
	 */
1685
	private function doGetAlbumInfo(string $id) : Response {
1686
		$albumId = $this->getAlbumIdFromEntityId($id);
1687
		if ($albumId === null) {
1688
			throw new SubsonicException("Unexpected ID format: $id", 0);
1689
		}
1690
		
1691
		$info = $this->lastfmService->getAlbumInfo($albumId, $this->user());
1692
1693
		if (isset($info['album'])) {
1694
			$content = [
1695
				'notes' => $info['album']['wiki']['summary'] ?? null,
1696
				'lastFmUrl' => $info['album']['url'],
1697
				'musicBrainzId' => $info['album']['mbid'] ?? null
1698
			];
1699
1700
			foreach ($info['album']['image'] ?? [] as $imageInfo) {
1701
				if (!empty($imageInfo['size'])) {
1702
					$content[$imageInfo['size'] . 'ImageUrl'] = $imageInfo['#text'];
1703
				}
1704
			}
1705
		} else {
1706
			$content = new \stdClass;
1707
		}
1708
1709
		// This method is unusual in how it uses non-attribute elements in the response.
1710
		return $this->subsonicResponse(['albumInfo' => $content], []);
1711
	}
1712
1713
	/**
1714
	 * Common logic for getSimilarSongs and getSimilarSongs2
1715
	 */
1716
	private function doGetSimilarSongs(string $rootName, string $id, int $count) : array {
1717
		$userId = $this->user();
1718
1719
		if (StringUtil::startsWith($id, 'artist')) {
1720
			$artistId = self::ripIdPrefix($id);
1721
		} elseif (StringUtil::startsWith($id, 'album')) {
1722
			$albumId = self::ripIdPrefix($id);
1723
			$artistId = $this->albumBusinessLayer->find($albumId, $userId)->getAlbumArtistId();
1724
		} elseif (StringUtil::startsWith($id, 'track')) {
1725
			$trackId = self::ripIdPrefix($id);
1726
			$artistId = $this->trackBusinessLayer->find($trackId, $userId)->getArtistId();
1727
		} else {
1728
			throw new SubsonicException("Id $id has a type not supported on getSimilarSongs", 0);
1729
		}
1730
1731
		$artists = $this->lastfmService->getSimilarArtists($artistId, $userId);
1732
		$artists[] = $this->artistBusinessLayer->find($artistId, $userId);
1733
1734
		// Get all songs by the found artists
1735
		$songs = [];
1736
		foreach ($artists as $artist) {
1737
			$songs = \array_merge($songs, $this->trackBusinessLayer->findAllByArtist($artist->getId(), $userId));
1738
		}
1739
1740
		// Randomly select the desired number of songs
1741
		$songs = $this->random->pickItems($songs, $count);
1742
1743
		return [$rootName => [
1744
			'song' => $this->tracksToApi($songs)
1745
		]];
1746
	}
1747
1748
	/**
1749
	 * Common logic for search2 and search3
1750
	 * @return array with keys 'artists', 'albums', and 'tracks'
1751
	 */
1752
	private function doSearch(string $query, int $artistCount, int $artistOffset,
1753
			int $albumCount, int $albumOffset, int $songCount, int $songOffset) : array {
1754
1755
		$userId = $this->user();
1756
1757
		// The searches support '*' as a wildcard. Convert those to the SQL wildcard '%' as that's what the business layer searches support.
1758
		$query = \str_replace('*', '%', $query);
1759
1760
		return [
1761
			'artists' => $this->artistBusinessLayer->findAllByName($query, $userId, MatchMode::Substring, $artistCount, $artistOffset),
1762
			'albums' => $this->albumBusinessLayer->findAllByNameRecursive($query, $userId, $albumCount, $albumOffset),
1763
			'tracks' => $this->trackBusinessLayer->findAllByNameRecursive($query, $userId, $songCount, $songOffset)
1764
		];
1765
	}
1766
1767
	/**
1768
	 * Common logic for getStarred and getStarred2
1769
	 */
1770
	private function doGetStarred() : array {
1771
		$userId = $this->user();
1772
		return [
1773
			'artists' => $this->artistBusinessLayer->findAllStarred($userId),
1774
			'albums' => $this->albumBusinessLayer->findAllStarred($userId),
1775
			'tracks' => $this->trackBusinessLayer->findAllStarred($userId)
1776
		];
1777
	}
1778
1779
	/**
1780
	 * Common response building logic for search2, search3, getStarred, and getStarred2
1781
	 * @param string $title Name of the main node in the response message
1782
	 * @param array $results Search results with keys 'artists', 'albums', and 'tracks'
1783
	 * @param bool $useNewApi Set to true for search3 and getStarred2. There is a difference
1784
	 *                        in the formatting of the album nodes.
1785
	 */
1786
	private function searchResponse(string $title, array $results, bool $useNewApi) : array {
1787
		$albumMapFunc = $useNewApi ? 'albumToNewApi' : 'albumToOldApi';
1788
1789
		return [$title => [
1790
			'artist' => \array_map([$this, 'artistToApi'], $results['artists']),
1791
			'album' => \array_map([$this, $albumMapFunc], $results['albums']),
1792
			'song' => $this->tracksToApi($results['tracks'])
1793
		]];
1794
	}
1795
1796
	/**
1797
	 * Find tracks by genre name
1798
	 * @return Track[]
1799
	 */
1800
	private function findTracksByGenre(string $genreName, ?int $limit=null, ?int $offset=null) : array {
1801
		$genre = $this->findGenreByName($genreName);
1802
1803
		if ($genre) {
1804
			return $this->trackBusinessLayer->findAllByGenre($genre->getId(), $this->user(), $limit, $offset);
1805
		} else {
1806
			return [];
1807
		}
1808
	}
1809
1810
	/**
1811
	 * Find albums by genre name
1812
	 * @return Album[]
1813
	 */
1814
	private function findAlbumsByGenre(string $genreName, ?int $limit=null, ?int $offset=null) : array {
1815
		$genre = $this->findGenreByName($genreName);
1816
1817
		if ($genre) {
1818
			return $this->albumBusinessLayer->findAllByGenre($genre->getId(), $this->user(), $limit, $offset);
1819
		} else {
1820
			return [];
1821
		}
1822
	}
1823
1824
	private function findGenreByName(string $name) : ?Genre {
1825
		$genreArr = $this->genreBusinessLayer->findAllByName($name, $this->user());
1826
		if (\count($genreArr) == 0 && $name == Genre::unknownNameString($this->l10n)) {
1827
			$genreArr = $this->genreBusinessLayer->findAllByName('', $this->user());
1828
		}
1829
		return \count($genreArr) ? $genreArr[0] : null;
1830
	}
1831
1832
	private function artistImageUrl(int $id) : string {
1833
		\assert($this->keyId !== null, 'function should not get called without authenticated user');
1834
		$token = $this->imageService->getToken('artist', $id, $this->keyId);
1835
		return $this->urlGenerator->linkToRouteAbsolute('music.ampacheImage.image',
1836
			['object_type' => 'artist', 'object_id' => $id, 'token' => $token, 'size' => CoverService::DO_NOT_CROP_OR_SCALE]);
1837
	}
1838
1839
	/**
1840
	 * Given a prefixed ID like 'artist-123' or 'track-45', return the string part and the numeric part.
1841
	 * @throws SubsonicException if the \a $id doesn't follow the expected pattern
1842
	 */
1843
	private static function parseEntityId(string $id) : array {
1844
		$parts = \explode('-', $id);
1845
		if (\count($parts) !== 2) {
1846
			throw new SubsonicException("Unexpected ID format: $id", 0);
1847
		}
1848
		$parts[1] = (int)$parts[1];
1849
		return $parts;
1850
	}
1851
1852
	/**
1853
	 * Given a prefixed ID like 'artist-123' or 'track-45', return just the numeric part.
1854
	 */
1855
	private static function ripIdPrefix(string $id) : int {
1856
		return self::parseEntityId($id)[1];
1857
	}
1858
1859
	/**
1860
	 * @param bool|string[] $useAttributes
1861
	 */
1862
	private function subsonicResponse(array $content, /*mixed*/ $useAttributes=true, string $status = 'ok') : Response {
1863
		$content['status'] = $status;
1864
		$content['version'] = self::API_VERSION;
1865
		$content['type'] = AppInfo::getFullName();
1866
		$content['serverVersion'] = AppInfo::getVersion();
1867
		$content['openSubsonic'] = true;
1868
		$responseData = ['subsonic-response' => ArrayUtil::rejectRecursive($content, 'is_null')];
1869
1870
		if ($this->format == 'json') {
1871
			$response = new JSONResponse($responseData);
1872
		} elseif ($this->format == 'jsonp') {
1873
			$responseData = \json_encode($responseData);
1874
			$response = new DataDisplayResponse("{$this->callback}($responseData);");
1875
			$response->addHeader('Content-Type', 'text/javascript; charset=UTF-8');
1876
		} else {
1877
			if (\is_array($useAttributes)) {
1878
				$useAttributes = \array_merge($useAttributes, ['status', 'version', 'type', 'serverVersion', 'xmlns']);
1879
			}
1880
			$responseData['subsonic-response']['xmlns'] = 'http://subsonic.org/restapi';
1881
			$response = new XmlResponse($responseData, $useAttributes);
1882
		}
1883
1884
		return $response;
1885
	}
1886
1887
	public function subsonicErrorResponse(int $errorCode, string $errorMessage) : Response {
1888
		return $this->subsonicResponse([
1889
				'error' => [
1890
					'code' => $errorCode,
1891
					'message' => $errorMessage
1892
				]
1893
			], true, 'failed');
1894
	}
1895
}
1896