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

SubsonicController::podcastEpisodestoApi()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 2
rs 10
c 1
b 0
f 0
1
<?php declare(strict_types=1);
2
3
/**
4
 * ownCloud - Music app
5
 *
6
 * This file is licensed under the Affero General Public License version 3 or
7
 * later. See the COPYING file.
8
 *
9
 * @author Pauli Järvinen <[email protected]>
10
 * @copyright Pauli Järvinen 2019 - 2025
11
 */
12
13
namespace OCA\Music\Controller;
14
15
use OCP\AppFramework\ApiController;
16
use OCP\AppFramework\Http\DataDisplayResponse;
17
use OCP\AppFramework\Http\JSONResponse;
18
use OCP\AppFramework\Http\RedirectResponse;
19
use OCP\AppFramework\Http\Response;
20
use OCP\Files\File;
21
use OCP\Files\Folder;
22
use OCP\Files\Node;
23
use OCP\IConfig;
24
use OCP\IL10N;
25
use OCP\IRequest;
26
use OCP\IUserManager;
27
use OCP\IURLGenerator;
28
29
use OCA\Music\AppFramework\BusinessLayer\BusinessLayerException;
30
use OCA\Music\AppFramework\Core\Logger;
31
use OCA\Music\AppFramework\Utility\MethodAnnotationReader;
32
use OCA\Music\AppFramework\Utility\RequestParameterExtractor;
33
use OCA\Music\AppFramework\Utility\RequestParameterExtractorException;
34
35
use OCA\Music\BusinessLayer\AlbumBusinessLayer;
36
use OCA\Music\BusinessLayer\ArtistBusinessLayer;
37
use OCA\Music\BusinessLayer\BookmarkBusinessLayer;
38
use OCA\Music\BusinessLayer\GenreBusinessLayer;
39
use OCA\Music\BusinessLayer\PlaylistBusinessLayer;
40
use OCA\Music\BusinessLayer\PodcastChannelBusinessLayer;
41
use OCA\Music\BusinessLayer\PodcastEpisodeBusinessLayer;
42
use OCA\Music\BusinessLayer\RadioStationBusinessLayer;
43
use OCA\Music\BusinessLayer\TrackBusinessLayer;
44
45
use OCA\Music\Db\Album;
46
use OCA\Music\Db\Artist;
47
use OCA\Music\Db\Bookmark;
48
use OCA\Music\Db\Genre;
49
use OCA\Music\Db\MatchMode;
50
use OCA\Music\Db\PodcastEpisode;
51
use OCA\Music\Db\SortBy;
52
use OCA\Music\Db\Track;
53
54
use OCA\Music\Http\FileResponse;
55
use OCA\Music\Http\FileStreamResponse;
56
use OCA\Music\Http\XmlResponse;
57
58
use OCA\Music\Middleware\SubsonicException;
59
60
use OCA\Music\Service\AmpacheImageService;
61
use OCA\Music\Service\CoverService;
62
use OCA\Music\Service\DetailsService;
63
use OCA\Music\Service\LastfmService;
64
use OCA\Music\Service\LibrarySettings;
65
use OCA\Music\Service\PodcastService;
66
67
use OCA\Music\Utility\AppInfo;
68
use OCA\Music\Utility\ArrayUtil;
69
use OCA\Music\Utility\HttpUtil;
70
use OCA\Music\Utility\Random;
71
use OCA\Music\Utility\StringUtil;
72
use OCA\Music\Utility\Util;
73
74
class SubsonicController extends ApiController {
75
	const API_VERSION = '1.16.1';
76
	const FOLDER_ID_ARTISTS = -1;
77
	const FOLDER_ID_FOLDERS = -2;
78
79
	private AlbumBusinessLayer $albumBusinessLayer;
80
	private ArtistBusinessLayer $artistBusinessLayer;
81
	private BookmarkBusinessLayer $bookmarkBusinessLayer;
82
	private GenreBusinessLayer $genreBusinessLayer;
83
	private PlaylistBusinessLayer $playlistBusinessLayer;
84
	private PodcastChannelBusinessLayer $podcastChannelBusinessLayer;
85
	private PodcastEpisodeBusinessLayer $podcastEpisodeBusinessLayer;
86
	private RadioStationBusinessLayer $radioStationBusinessLayer;
87
	private TrackBusinessLayer $trackBusinessLayer;
88
	private IURLGenerator $urlGenerator;
89
	private IUserManager $userManager;
90
	private LibrarySettings $librarySettings;
91
	private IL10N $l10n;
92
	private CoverService $coverService;
93
	private DetailsService $detailsService;
94
	private LastfmService $lastfmService;
95
	private PodcastService $podcastService;
96
	private AmpacheImageService $imageService;
97
	private Random $random;
98
	private Logger $logger;
99
	private IConfig $configManager;
100
	private ?string $userId;
101
	private ?int $keyId;
102
	private array $ignoredArticles;
103
	private string $format;
104
	private ?string $callback;
105
106
	public function __construct(string $appName,
107
								IRequest $request,
108
								IL10N $l10n,
109
								IURLGenerator $urlGenerator,
110
								IUserManager $userManager,
111
								AlbumBusinessLayer $albumBusinessLayer,
112
								ArtistBusinessLayer $artistBusinessLayer,
113
								BookmarkBusinessLayer $bookmarkBusinessLayer,
114
								GenreBusinessLayer $genreBusinessLayer,
115
								PlaylistBusinessLayer $playlistBusinessLayer,
116
								PodcastChannelBusinessLayer $podcastChannelBusinessLayer,
117
								PodcastEpisodeBusinessLayer $podcastEpisodeBusinessLayer,
118
								RadioStationBusinessLayer $radioStationBusinessLayer,
119
								TrackBusinessLayer $trackBusinessLayer,
120
								LibrarySettings $librarySettings,
121
								CoverService $coverService,
122
								DetailsService $detailsService,
123
								LastfmService $lastfmService,
124
								PodcastService $podcastService,
125
								AmpacheImageService $imageService,
126
								Random $random,
127
								Logger $logger,
128
								\OCP\IConfig $configManager) {
129
		parent::__construct($appName, $request, 'POST, GET', 'Authorization, Content-Type, Accept, X-Requested-With');
130
131
		$this->albumBusinessLayer = $albumBusinessLayer;
132
		$this->artistBusinessLayer = $artistBusinessLayer;
133
		$this->bookmarkBusinessLayer = $bookmarkBusinessLayer;
134
		$this->genreBusinessLayer = $genreBusinessLayer;
135
		$this->playlistBusinessLayer = $playlistBusinessLayer;
136
		$this->podcastChannelBusinessLayer = $podcastChannelBusinessLayer;
137
		$this->podcastEpisodeBusinessLayer = $podcastEpisodeBusinessLayer;
138
		$this->radioStationBusinessLayer = $radioStationBusinessLayer;
139
		$this->trackBusinessLayer = $trackBusinessLayer;
140
		$this->urlGenerator = $urlGenerator;
141
		$this->userManager = $userManager;
142
		$this->l10n = $l10n;
143
		$this->librarySettings = $librarySettings;
144
		$this->coverService = $coverService;
145
		$this->detailsService = $detailsService;
146
		$this->lastfmService = $lastfmService;
147
		$this->podcastService = $podcastService;
148
		$this->imageService = $imageService;
149
		$this->random = $random;
150
		$this->logger = $logger;
151
		$this->configManager = $configManager;
152
		$this->userId = null;
153
		$this->keyId = null;
154
		$this->ignoredArticles = [];
155
		$this->format = 'xml'; // default, should be immediately overridden by SubsonicMiddleware
156
	}
157
158
	/**
159
	 * Called by the middleware to set the response format to be used
160
	 * @param string $format Response format: xml/json/jsonp
161
	 * @param string|null $callback Function name to use if the @a $format is 'jsonp'
162
	 */
163
	public function setResponseFormat(string $format, ?string $callback = null) : void {
164
		$this->format = $format;
165
		$this->callback = $callback;
166
	}
167
168
	/**
169
	 * Called by the middleware once the user credentials have been checked
170
	 */
171
	public function setAuthenticatedUser(string $userId, int $keyId) : void {
172
		$this->userId = $userId;
173
		$this->keyId = $keyId;
174
		$this->ignoredArticles = $this->librarySettings->getIgnoredArticles($userId);
175
	}
176
177
	/**
178
	 * @NoAdminRequired
179
	 * @PublicPage
180
	 * @NoCSRFRequired
181
	 * @NoSameSiteCookieRequired
182
	 * @CORS
183
	 */
184
	public function handleRequest(string $method) : Response {
185
		$this->logger->debug("Subsonic request $method");
186
187
		// Allow calling all methods with or without the postfix ".view"
188
		if (StringUtil::endsWith($method, ".view")) {
189
			$method = \substr($method, 0, -\strlen(".view"));
190
		}
191
192
		// There's only one method allowed without a logged-in user
193
		if ($method !== 'getOpenSubsonicExtensions' && $this->userId === null) {
194
			throw new SubsonicException('User authentication required', 10);
195
		}
196
197
		// Allow calling any functions annotated to be part of the API
198
		if (\method_exists($this, $method)) {
199
			$annotationReader = new MethodAnnotationReader($this, $method);
200
			if ($annotationReader->hasAnnotation('SubsonicAPI')) {
201
				$parameterExtractor = new RequestParameterExtractor($this->request);
202
				try {
203
					$parameterValues = $parameterExtractor->getParametersForMethod($this, $method);
204
				} catch (RequestParameterExtractorException $ex) {
205
					return $this->subsonicErrorResponse(10, $ex->getMessage());
206
				}
207
				$response = \call_user_func_array([$this, $method], $parameterValues);
208
				// The API methods may return either a Response object or an array, which should be converted to Response
209
				if (!($response instanceof Response)) {
210
					$response = $this->subsonicResponse($response);
211
				}
212
				return $response;
213
			}
214
		}
215
216
		$this->logger->warning("Request $method not supported");
217
		return $this->subsonicErrorResponse(0, "Requested action $method is not supported");
218
	}
219
220
	/* -------------------------------------------------------------------------
221
	 * REST API methods
222
	 * -------------------------------------------------------------------------
223
	 */
224
225
	/**
226
	 * @SubsonicAPI
227
	 */
228
	protected function ping() : array {
229
		return [];
230
	}
231
232
	/**
233
	 * @SubsonicAPI
234
	 */
235
	protected function getLicense() : array {
236
		return [
237
			'license' => [
238
				'valid' => true
239
			]
240
		];
241
	}
242
243
	/**
244
	 * @SubsonicAPI
245
	 */
246
	protected function getMusicFolders() : array {
247
		// Only single root folder is supported
248
		return [
249
			'musicFolders' => ['musicFolder' => [
250
				['id' => self::FOLDER_ID_ARTISTS, 'name' => $this->l10n->t('Artists')],
251
				['id' => self::FOLDER_ID_FOLDERS, 'name' => $this->l10n->t('Folders')]
252
			]]
253
		];
254
	}
255
256
	/**
257
	 * @SubsonicAPI
258
	 */
259
	protected function getIndexes(?int $musicFolderId) : array {
260
		if ($musicFolderId === self::FOLDER_ID_FOLDERS) {
261
			return $this->getIndexesForFolders();
262
		} else {
263
			return $this->getIndexesForArtists();
264
		}
265
	}
266
267
	/**
268
	 * @SubsonicAPI
269
	 */
270
	protected function getMusicDirectory(string $id) : array {
271
		if (StringUtil::startsWith($id, 'folder-')) {
272
			return $this->getMusicDirectoryForFolder($id);
273
		} elseif (StringUtil::startsWith($id, 'artist-')) {
274
			return $this->getMusicDirectoryForArtist($id);
275
		} elseif (StringUtil::startsWith($id, 'album-')) {
276
			return $this->getMusicDirectoryForAlbum($id);
277
		} elseif (StringUtil::startsWith($id, 'podcast_channel-')) {
278
			return $this->getMusicDirectoryForPodcastChannel($id);
279
		} else {
280
			throw new SubsonicException("Unsupported id format $id");
281
		}
282
	}
283
284
	/**
285
	 * @SubsonicAPI
286
	 */
287
	protected function getAlbumList(
288
			string $type, ?string $genre, ?int $fromYear, ?int $toYear, int $size=10, int $offset=0) : array {
289
		$albums = $this->albumsForGetAlbumList($type, $genre, $fromYear, $toYear, $size, $offset);
290
		return ['albumList' => [
291
			'album' => \array_map([$this, 'albumToOldApi'], $albums)
292
		]];
293
	}
294
295
	/**
296
	 * @SubsonicAPI
297
	 */
298
	protected function getAlbumList2(
299
			string $type, ?string $genre, ?int $fromYear, ?int $toYear, int $size=10, int $offset=0) : array {
300
		/*
301
		 * According to the API specification, the difference between this and getAlbumList
302
		 * should be that this function would organize albums according the metadata while
303
		 * getAlbumList would organize them by folders. However, we organize by metadata
304
		 * also in getAlbumList, because that's more natural for the Music app and many/most
305
		 * clients do not support getAlbumList2.
306
		 */
307
		$albums = $this->albumsForGetAlbumList($type, $genre, $fromYear, $toYear, $size, $offset);
308
		return ['albumList2' => [
309
			'album' => \array_map([$this, 'albumToNewApi'], $albums)
310
		]];
311
	}
312
313
	/**
314
	 * @SubsonicAPI
315
	 */
316
	protected function getArtists() : array {
317
		return $this->getIndexesForArtists('artists');
318
	}
319
320
	/**
321
	 * @SubsonicAPI
322
	 */
323
	protected function getArtist(string $id) : array {
324
		$artistId = self::ripIdPrefix($id); // get rid of 'artist-' prefix
325
326
		$artist = $this->artistBusinessLayer->find($artistId, $this->user());
327
		$albums = $this->albumBusinessLayer->findAllByArtist($artistId, $this->user());
328
329
		$artistNode = $this->artistToApi($artist);
330
		$artistNode['album'] = \array_map([$this, 'albumToNewApi'], $albums);
331
332
		return ['artist' => $artistNode];
333
	}
334
335
	/**
336
	 * @SubsonicAPI
337
	 */
338
	protected function getArtistInfo(string $id, bool $includeNotPresent=false) : Response {
339
		return $this->doGetArtistInfo('artistInfo', $id, $includeNotPresent);
340
	}
341
342
	/**
343
	 * @SubsonicAPI
344
	 */
345
	protected function getArtistInfo2(string $id, bool $includeNotPresent=false) : Response {
346
		return $this->doGetArtistInfo('artistInfo2', $id, $includeNotPresent);
347
	}
348
349
	/**
350
	 * @SubsonicAPI
351
	 */
352
	protected function getAlbumInfo(string $id) : Response {
353
		return $this->doGetAlbumInfo($id);
354
	}
355
356
	/**
357
	 * @SubsonicAPI
358
	 */
359
	protected function getAlbumInfo2(string $id) : Response {
360
		return $this->doGetAlbumInfo($id);
361
	}
362
363
	/**
364
	 * @SubsonicAPI
365
	 */
366
	protected function getSimilarSongs(string $id, int $count=50) : array {
367
		return $this->doGetSimilarSongs('similarSongs', $id, $count);
368
	}
369
370
	/**
371
	 * @SubsonicAPI
372
	 */
373
	protected function getSimilarSongs2(string $id, int $count=50) : array {
374
		return $this->doGetSimilarSongs('similarSongs2', $id, $count);
375
	}
376
377
	/**
378
	 * @SubsonicAPI
379
	 */
380
	protected function getTopSongs(string $artist, int $count=50) : array {
381
		$tracks = $this->lastfmService->getTopTracks($artist, $this->user(), $count);
382
		return ['topSongs' => [
383
			'song' => $this->tracksToApi($tracks)
384
		]];
385
	}
386
387
	/**
388
	 * @SubsonicAPI
389
	 */
390
	protected function getAlbum(string $id) : array {
391
		$albumId = self::ripIdPrefix($id); // get rid of 'album-' prefix
392
393
		$album = $this->albumBusinessLayer->find($albumId, $this->user());
394
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $this->user());
395
396
		$albumNode = $this->albumToNewApi($album);
397
		$albumNode['song'] = $this->tracksToApi($tracks);
398
		return ['album' => $albumNode];
399
	}
400
401
	/**
402
	 * @SubsonicAPI
403
	 */
404
	protected function getSong(string $id) : array {
405
		$trackId = self::ripIdPrefix($id); // get rid of 'track-' prefix
406
		$track = $this->trackBusinessLayer->find($trackId, $this->user());
407
		return ['song' => $this->trackToApi($track)];
408
	}
409
410
	/**
411
	 * @SubsonicAPI
412
	 */
413
	protected function getRandomSongs(?string $genre, ?string $fromYear, ?string $toYear, int $size=10) : array {
414
		$size = \min($size, 500); // the API spec limits the maximum amount to 500
415
416
		if ($genre !== null) {
417
			$trackPool = $this->findTracksByGenre($genre);
418
		} else {
419
			$trackPool = $this->trackBusinessLayer->findAll($this->user());
420
		}
421
422
		if ($fromYear !== null) {
423
			$trackPool = \array_filter($trackPool, fn($track) => ($track->getYear() !== null && $track->getYear() >= $fromYear));
424
		}
425
426
		if ($toYear !== null) {
427
			$trackPool = \array_filter($trackPool, fn($track) => ($track->getYear() !== null && $track->getYear() <= $toYear));
428
		}
429
430
		$tracks = Random::pickItems($trackPool, $size);
431
432
		return ['randomSongs' => [
433
			'song' => $this->tracksToApi($tracks)
434
		]];
435
	}
436
437
	/**
438
	 * @SubsonicAPI
439
	 */
440
	protected function getCoverArt(string $id, ?int $size) : Response {
441
		list($type, $entityId) = self::parseEntityId($id);
442
		$userId = $this->user();
443
444
		if ($type == 'album') {
445
			$entity = $this->albumBusinessLayer->find($entityId, $userId);
446
		} elseif ($type == 'artist') {
447
			$entity = $this->artistBusinessLayer->find($entityId, $userId);
448
		} elseif ($type == 'podcast_channel') {
449
			$entity = $this->podcastService->getChannel($entityId, $userId, /*$includeEpisodes=*/ false);
450
		} elseif ($type == 'pl') {
451
			$entity = $this->playlistBusinessLayer->find($entityId, $userId);
452
		}
453
454
		if (!empty($entity)) {
455
			$rootFolder = $this->librarySettings->getFolder($userId);
456
			$coverData = $this->coverService->getCover($entity, $userId, $rootFolder, $size);
457
			$response = new FileResponse($coverData);
458
			HttpUtil::setClientCachingDays($response, 30);
459
			return $response;
460
		}
461
462
		return $this->subsonicErrorResponse(70, "entity $id has no cover");
463
	}
464
465
	/**
466
	 * @SubsonicAPI
467
	 */
468
	protected function getLyrics(?string $artist, ?string $title) : array {
469
		$userId = $this->user();
470
		$matches = $this->trackBusinessLayer->findAllByNameArtistOrAlbum($title, $artist, null, $userId);
471
		$matchingCount = \count($matches);
472
473
		if ($matchingCount === 0) {
474
			$this->logger->debug("No matching track for title '$title' and artist '$artist'");
475
			return ['lyrics' => new \stdClass];
476
		} else {
477
			if ($matchingCount > 1) {
478
				$this->logger->debug("Found $matchingCount tracks matching title ".
479
								"'$title' and artist '$artist'; using the first");
480
			}
481
			$track = $matches[0];
482
483
			$artistObj = $this->artistBusinessLayer->find($track->getArtistId(), $userId);
484
			$rootFolder = $this->librarySettings->getFolder($userId);
485
			$lyrics = $this->detailsService->getLyricsAsPlainText($track->getFileId(), $rootFolder);
486
487
			return ['lyrics' => [
488
				'artist' => $artistObj->getNameString($this->l10n),
489
				'title' => $track->getTitle(),
490
				'value' => $lyrics
491
			]];
492
		}
493
	}
494
495
	/**
496
	 * OpenSubsonic extension
497
	 * @SubsonicAPI
498
	 */
499
	protected function getLyricsBySongId(string $id) : array {
500
		$userId = $this->user();
501
		$trackId = self::ripIdPrefix($id); // get rid of 'track-' prefix
502
		$track = $this->trackBusinessLayer->find($trackId, $userId);
503
		$artist = $this->artistBusinessLayer->find($track->getArtistId(), $userId);
504
		$rootFolder = $this->librarySettings->getFolder($userId);
505
		$allLyrics = $this->detailsService->getLyricsAsStructured($track->getFileId(), $rootFolder);
506
507
		return ['lyricsList' => [
508
			'structuredLyrics' => \array_map(function ($lyrics) use ($track, $artist) {
509
				$isSynced = $lyrics['synced'];
510
				return [
511
					'displayArtist' => $artist->getNameString($this->l10n),
512
					'displayTitle' => $track->getTitle(),
513
					'lang' => 'xxx',
514
					'offset' => 0,
515
					'synced' => $isSynced,
516
					'line' => \array_map(function($lineVal, $lineKey) use ($isSynced) {
517
						$line = ['value' => \trim($lineVal)];
518
						if ($isSynced) {
519
							$line['start'] = $lineKey;
520
						};
521
						return $line;
522
					}, $lyrics['lines'], \array_keys($lyrics['lines']))
523
				];
524
			}, $allLyrics) 
525
		]];
526
	}
527
528
	/**
529
	 * @SubsonicAPI
530
	 */
531
	protected function stream(string $id) : Response {
532
		// We don't support transcoding, so 'stream' and 'download' act identically
533
		return $this->download($id);
534
	}
535
536
	/**
537
	 * @SubsonicAPI
538
	 */
539
	protected function download(string $id) : Response {
540
		list($type, $entityId) = self::parseEntityId($id);
541
542
		if ($type === 'track') {
543
			$track = $this->trackBusinessLayer->find($entityId, $this->user());
544
			$file = $this->getFilesystemNode($track->getFileId());
545
546
			if ($file instanceof File) {
547
				return new FileStreamResponse($file);
548
			} else {
549
				return $this->subsonicErrorResponse(70, 'file not found');
550
			}
551
		} elseif ($type === 'podcast_episode') {
552
			$episode = $this->podcastService->getEpisode($entityId, $this->user());
553
			if ($episode instanceof PodcastEpisode) {
554
				return new RedirectResponse($episode->getStreamUrl());
555
			} else {
556
				return $this->subsonicErrorResponse(70, 'episode not found');
557
			}
558
		} else {
559
			return $this->subsonicErrorResponse(0, "id of type $type not supported");
560
		}
561
	}
562
563
	/**
564
	 * @SubsonicAPI
565
	 */
566
	protected function search2(string $query, int $artistCount=20, int $artistOffset=0,
567
			int $albumCount=20, int $albumOffset=0, int $songCount=20, int $songOffset=0) : array {
568
		$results = $this->doSearch($query, $artistCount, $artistOffset, $albumCount, $albumOffset, $songCount, $songOffset);
569
		return $this->searchResponse('searchResult2', $results, /*$useNewApi=*/false);
570
	}
571
572
	/**
573
	 * @SubsonicAPI
574
	 */
575
	protected function search3(string $query, int $artistCount=20, int $artistOffset=0,
576
			int $albumCount=20, int $albumOffset=0, int $songCount=20, int $songOffset=0) : array {
577
		$results = $this->doSearch($query, $artistCount, $artistOffset, $albumCount, $albumOffset, $songCount, $songOffset);
578
		return $this->searchResponse('searchResult3', $results, /*$useNewApi=*/true);
579
	}
580
581
	/**
582
	 * @SubsonicAPI
583
	 */
584
	protected function getGenres() : array {
585
		$genres = $this->genreBusinessLayer->findAll($this->user(), SortBy::Name);
586
587
		return ['genres' => [
588
			'genre' => \array_map(fn($genre) => [
589
				'songCount' => $genre->getTrackCount(),
590
				'albumCount' => $genre->getAlbumCount(),
591
				'value' => $genre->getNameString($this->l10n)
592
			], $genres)
593
		]];
594
	}
595
596
	/**
597
	 * @SubsonicAPI
598
	 */
599
	protected function getSongsByGenre(string $genre, int $count=10, int $offset=0) : array {
600
		$tracks = $this->findTracksByGenre($genre, $count, $offset);
601
602
		return ['songsByGenre' => [
603
			'song' => $this->tracksToApi($tracks)
604
		]];
605
	}
606
607
	/**
608
	 * @SubsonicAPI
609
	 */
610
	protected function getPlaylists() : array {
611
		$userId = $this->user();
612
		$playlists = $this->playlistBusinessLayer->findAll($userId);
613
614
		foreach ($playlists as $playlist) {
615
			$playlist->setDuration($this->playlistBusinessLayer->getDuration($playlist->getId(), $userId));
616
		}
617
618
		return ['playlists' => [
619
			'playlist' => \array_map(fn($p) => $p->toSubsonicApi(), $playlists)
620
		]];
621
	}
622
623
	/**
624
	 * @SubsonicAPI
625
	 */
626
	protected function getPlaylist(int $id) : array {
627
		$userId = $this->user();
628
		$playlist = $this->playlistBusinessLayer->find($id, $userId);
629
		$tracks = $this->playlistBusinessLayer->getPlaylistTracks($id, $userId);
630
		$playlist->setDuration(\array_reduce($tracks, function (?int $accuDuration, Track $track) : int {
631
			return (int)$accuDuration + (int)$track->getLength();
632
		}));
633
634
		$playlistNode = $playlist->toSubsonicApi();
635
		$playlistNode['entry'] = $this->tracksToApi($tracks);
636
637
		return ['playlist' => $playlistNode];
638
	}
639
640
	/**
641
	 * @SubsonicAPI
642
	 */
643
	protected function createPlaylist(?string $name, ?string $playlistId, array $songId) : array {
644
		$songIds = \array_map('self::ripIdPrefix', $songId);
645
646
		// If playlist ID has been passed, then this method actually updates an existing list instead of creating a new one.
647
		// The updating can't be used to rename the list, even if both ID and name are given (this is how the real Subsonic works, too).
648
		if (!empty($playlistId)) {
649
			$playlistId = (int)$playlistId;
650
		} elseif (!empty($name)) {
651
			$playlist = $this->playlistBusinessLayer->create($name, $this->user());
652
			$playlistId = $playlist->getId();
653
		} else {
654
			throw new SubsonicException('Playlist ID or name must be specified.', 10);
655
		}
656
657
		$this->playlistBusinessLayer->setTracks($songIds, $playlistId, $this->user());
658
659
		return $this->getPlaylist($playlistId);
660
	}
661
662
	/**
663
	 * @SubsonicAPI
664
	 */
665
	protected function updatePlaylist(int $playlistId, ?string $name, ?string $comment, array $songIdToAdd, array $songIndexToRemove) : array {
666
		$songIdsToAdd = \array_map('self::ripIdPrefix', $songIdToAdd);
667
		$songIndicesToRemove = \array_map('intval', $songIndexToRemove);
668
		$userId = $this->user();
669
670
		if (!empty($name)) {
671
			$this->playlistBusinessLayer->rename($name, $playlistId, $userId);
672
		}
673
674
		if ($comment !== null) {
675
			$this->playlistBusinessLayer->setComment($comment, $playlistId, $userId);
676
		}
677
678
		if (!empty($songIndicesToRemove)) {
679
			$this->playlistBusinessLayer->removeTracks($songIndicesToRemove, $playlistId, $userId);
680
		}
681
682
		if (!empty($songIdsToAdd)) {
683
			$this->playlistBusinessLayer->addTracks($songIdsToAdd, $playlistId, $userId);
684
		}
685
686
		return [];
687
	}
688
689
	/**
690
	 * @SubsonicAPI
691
	 */
692
	protected function deletePlaylist(int $id) : array {
693
		$this->playlistBusinessLayer->delete($id, $this->user());
694
		return [];
695
	}
696
697
	/**
698
	 * @SubsonicAPI
699
	 */
700
	protected function getInternetRadioStations() : array {
701
		$stations = $this->radioStationBusinessLayer->findAll($this->user());
702
703
		return ['internetRadioStations' => [
704
			'internetRadioStation' => \array_map(fn($station) => [
705
				'id' => $station->getId(),
706
				'name' => $station->getName() ?: $station->getStreamUrl(),
707
				'streamUrl' => $station->getStreamUrl(),
708
				'homePageUrl' => $station->getHomeUrl()
709
			], $stations)
710
		]];
711
	}
712
713
	/**
714
	 * @SubsonicAPI
715
	 */
716
	protected function createInternetRadioStation(string $streamUrl, string $name, ?string $homepageUrl) : array {
717
		$this->radioStationBusinessLayer->create($this->user(), $name, $streamUrl, $homepageUrl);
718
		return [];
719
	}
720
721
	/**
722
	 * @SubsonicAPI
723
	 */
724
	protected function updateInternetRadioStation(int $id, string $streamUrl, string $name, ?string $homepageUrl) : array {
725
		$this->radioStationBusinessLayer->updateStation($id, $this->user(), $name, $streamUrl, $homepageUrl);
726
		return [];
727
	}
728
729
	/**
730
	 * @SubsonicAPI
731
	 */
732
	protected function deleteInternetRadioStation(int $id) : array {
733
		$this->radioStationBusinessLayer->delete($id, $this->user());
734
		return [];
735
	}
736
737
	/**
738
	 * @SubsonicAPI
739
	 */
740
	protected function getUser(string $username) : array {
741
		$userId = $this->user();
742
		if (\mb_strtolower($username) != \mb_strtolower($userId)) {
743
			throw new SubsonicException("$userId is not authorized to get details for other users.", 50);
744
		}
745
746
		$user = $this->userManager->get($userId);
747
748
		return [
749
			'user' => [
750
				'username' => $userId,
751
				'email' => $user->getEMailAddress(),
752
				'scrobblingEnabled' => true,
753
				'adminRole' => false,
754
				'settingsRole' => false,
755
				'downloadRole' => true,
756
				'uploadRole' => false,
757
				'playlistRole' => true,
758
				'coverArtRole' => false,
759
				'commentRole' => true,
760
				'podcastRole' => true,
761
				'streamRole' => true,
762
				'jukeboxRole' => false,
763
				'shareRole' => false,
764
				'videoConversionRole' => false,
765
				'folder' => [self::FOLDER_ID_ARTISTS, self::FOLDER_ID_FOLDERS],
766
			]
767
		];
768
	}
769
770
	/**
771
	 * @SubsonicAPI
772
	 */
773
	protected function getUsers() : array {
774
		throw new SubsonicException("{$this->user()} is not authorized to get details for other users.", 50);
775
	}
776
777
	/**
778
	 * @SubsonicAPI
779
	 */
780
	protected function getAvatar(string $username) : Response {
781
		$userId = $this->user();
782
		if (\mb_strtolower($username) != \mb_strtolower($userId)) {
783
			throw new SubsonicException("$userId is not authorized to get avatar for other users.", 50);
784
		}
785
786
		$image = $this->userManager->get($userId)->getAvatarImage(150);
787
788
		if ($image !== null) {
789
			return new FileResponse(['content' => $image->data(), 'mimetype' => $image->mimeType()]);
790
		} else {
791
			return $this->subsonicErrorResponse(70, 'user has no avatar');
792
		}
793
	}
794
795
	/**
796
	 * OpenSubsonic extension
797
	 * @SubsonicAPI
798
	 */
799
	protected function tokenInfo() : array {
800
		// This method is intended to be used when API key is used for authentication and the user name is not
801
		// directly available for the client. But it shouldn't hurt to allow calling this regardless of the
802
		// authentication method.
803
		return ['tokenInfo' => ['username' => $this->user()]];
804
	}
805
806
	/**
807
	 * @SubsonicAPI
808
	 */
809
	protected function scrobble(array $id, array $time) : array {
810
		if (\count($id) === 0) {
811
			throw new SubsonicException("Required parameter 'id' missing", 10);
812
		}
813
814
		$userId = $this->user();
815
		foreach ($id as $index => $aId) {
816
			list($type, $trackId) = self::parseEntityId($aId);
817
			if ($type === 'track') {
818
				if (isset($time[$index])) {
819
					$timestamp = \substr($time[$index], 0, -3); // cut down from milliseconds to seconds
820
					$timeOfPlay = new \DateTime('@' . $timestamp);
821
				} else {
822
					$timeOfPlay = null;
823
				}
824
				$this->trackBusinessLayer->recordTrackPlayed((int)$trackId, $userId, $timeOfPlay);
825
			}
826
		}
827
828
		return [];
829
	}
830
831
	/**
832
	 * @SubsonicAPI
833
	 */
834
	protected function star(array $id, array $albumId, array $artistId) : array {
835
		$targetIds = self::parseStarringParameters($id, $albumId, $artistId);
836
		$userId = $this->user();
837
838
		$this->trackBusinessLayer->setStarred($targetIds['tracks'], $userId);
839
		$this->albumBusinessLayer->setStarred($targetIds['albums'], $userId);
840
		$this->artistBusinessLayer->setStarred($targetIds['artists'], $userId);
841
		$this->podcastChannelBusinessLayer->setStarred($targetIds['podcast_channels'], $userId);
842
		$this->podcastEpisodeBusinessLayer->setStarred($targetIds['podcast_episodes'], $userId);
843
844
		return [];
845
	}
846
847
	/**
848
	 * @SubsonicAPI
849
	 */
850
	protected function unstar(array $id, array $albumId, array $artistId) : array {
851
		$targetIds = self::parseStarringParameters($id, $albumId, $artistId);
852
		$userId = $this->user();
853
854
		$this->trackBusinessLayer->unsetStarred($targetIds['tracks'], $userId);
855
		$this->albumBusinessLayer->unsetStarred($targetIds['albums'], $userId);
856
		$this->artistBusinessLayer->unsetStarred($targetIds['artists'], $userId);
857
		$this->podcastChannelBusinessLayer->unsetStarred($targetIds['podcast_channels'], $userId);
858
		$this->podcastEpisodeBusinessLayer->unsetStarred($targetIds['podcast_episodes'], $userId);
859
860
		return [];
861
	}
862
863
	/**
864
	 * @SubsonicAPI
865
	 */
866
	protected function setRating(string $id, int $rating) : array {
867
		$rating = (int)Util::limit($rating, 0, 5);
868
		list($type, $entityId) = self::parseEntityId($id);
869
870
		switch ($type) {
871
			case 'track':
872
				$bLayer = $this->trackBusinessLayer;
873
				break;
874
			case 'album':
875
				$bLayer = $this->albumBusinessLayer;
876
				break;
877
			case 'artist':
878
				$bLayer = $this->artistBusinessLayer;
879
				break;
880
			case 'podcast_episode':
881
				$bLayer = $this->podcastEpisodeBusinessLayer;
882
				break;
883
			case 'folder':
884
				throw new SubsonicException('Rating folders is not supported', 0);
885
			default:
886
				throw new SubsonicException("Unexpected ID format: $id", 0);
887
		}
888
889
		$bLayer->setRating($entityId, $rating, $this->user());
890
891
		return [];
892
	}
893
894
	/**
895
	 * @SubsonicAPI
896
	 */
897
	protected function getStarred() : array {
898
		$starred = $this->doGetStarred();
899
		return $this->searchResponse('starred', $starred, /*$useNewApi=*/false);
900
	}
901
902
	/**
903
	 * @SubsonicAPI
904
	 */
905
	protected function getStarred2() : array {
906
		$starred = $this->doGetStarred();
907
		return $this->searchResponse('starred2', $starred, /*$useNewApi=*/true);
908
	}
909
910
	/**
911
	 * @SubsonicAPI
912
	 */
913
	protected function getVideos() : array {
914
		// Feature not supported, return an empty list
915
		return [
916
			'videos' => [
917
				'video' => []
918
			]
919
		];
920
	}
921
922
	/**
923
	 * @SubsonicAPI
924
	 */
925
	protected function getPodcasts(?string $id, bool $includeEpisodes = true) : array {
926
		if ($id !== null) {
927
			$id = self::ripIdPrefix($id);
928
			$channel = $this->podcastService->getChannel($id, $this->user(), $includeEpisodes);
929
			if ($channel === null) {
930
				throw new SubsonicException('Requested channel not found', 70);
931
			}
932
			$channels = [$channel];
933
		} else {
934
			$channels = $this->podcastService->getAllChannels($this->user(), $includeEpisodes);
935
		}
936
937
		return [
938
			'podcasts' => [
939
				'channel' => \array_map(fn($c) => $c->toSubsonicApi(), $channels)
940
			]
941
		];
942
	}
943
944
	/**
945
	 * OpenSubsonic extension
946
	 * @SubsonicAPI
947
	 */
948
	protected function getPodcastEpisode(string $id) : array {
949
		$id = self::ripIdPrefix($id);
950
		$episode = $this->podcastService->getEpisode($id, $this->user());
951
952
		if ($episode === null) {
953
			throw new SubsonicException('Requested episode not found', 70);
954
		}
955
956
		return [
957
			'podcastEpisode' => $episode->toSubsonicApi()
958
		];
959
	}
960
961
	/**
962
	 * @SubsonicAPI
963
	 */
964
	protected function getNewestPodcasts(int $count=20) : array {
965
		$episodes = $this->podcastService->getLatestEpisodes($this->user(), $count);
966
967
		return [
968
			'newestPodcasts' => [
969
				'episode' => \array_map(fn($e) => $e->toSubsonicApi(), $episodes)
970
			]
971
		];
972
	}
973
974
	/**
975
	 * @SubsonicAPI
976
	 */
977
	protected function refreshPodcasts() : array {
978
		$this->podcastService->updateAllChannels($this->user());
979
		return [];
980
	}
981
982
	/**
983
	 * @SubsonicAPI
984
	 */
985
	protected function createPodcastChannel(string $url) : array {
986
		$result = $this->podcastService->subscribe($url, $this->user());
987
988
		switch ($result['status']) {
989
			case PodcastService::STATUS_OK:
990
				return [];
991
			case PodcastService::STATUS_INVALID_URL:
992
				throw new SubsonicException("Invalid URL $url", 0);
993
			case PodcastService::STATUS_INVALID_RSS:
994
				throw new SubsonicException("The document at URL $url is not a valid podcast RSS feed", 0);
995
			case PodcastService::STATUS_ALREADY_EXISTS:
996
				throw new SubsonicException('User already has this podcast channel subscribed', 0);
997
			default:
998
				throw new SubsonicException("Unexpected status code {$result['status']}", 0);
999
		}
1000
	}
1001
1002
	/**
1003
	 * @SubsonicAPI
1004
	 */
1005
	protected function deletePodcastChannel(string $id) : array {
1006
		$id = self::ripIdPrefix($id);
1007
		$status = $this->podcastService->unsubscribe($id, $this->user());
1008
1009
		switch ($status) {
1010
			case PodcastService::STATUS_OK:
1011
				return [];
1012
			case PodcastService::STATUS_NOT_FOUND:
1013
				throw new SubsonicException('Channel to be deleted not found', 70);
1014
			default:
1015
				throw new SubsonicException("Unexpected status code $status", 0);
1016
		}
1017
	}
1018
1019
	/**
1020
	 * @SubsonicAPI
1021
	 */
1022
	protected function getBookmarks() : array {
1023
		$userId = $this->user();
1024
		$bookmarkNodes = [];
1025
		$bookmarks = $this->bookmarkBusinessLayer->findAll($userId);
1026
1027
		foreach ($bookmarks as $bookmark) {
1028
			$node = $bookmark->toSubsonicApi();
1029
			$entryId = $bookmark->getEntryId();
1030
			$type = $bookmark->getType();
1031
1032
			try {
1033
				if ($type === Bookmark::TYPE_TRACK) {
1034
					$track = $this->trackBusinessLayer->find($entryId, $userId);
1035
					$node['entry'] = $this->trackToApi($track);
1036
				} elseif ($type === Bookmark::TYPE_PODCAST_EPISODE) {
1037
					$node['entry'] = $this->podcastEpisodeBusinessLayer->find($entryId, $userId)->toSubsonicApi();
1038
				} else {
1039
					$this->logger->warning("Bookmark {$bookmark->getId()} had unexpected entry type $type");
1040
				}
1041
				$bookmarkNodes[] = $node;
1042
			} catch (BusinessLayerException $e) {
1043
				$this->logger->warning("Bookmarked entry with type $type and id $entryId not found");
1044
			}
1045
		}
1046
1047
		return ['bookmarks' => ['bookmark' => $bookmarkNodes]];
1048
	}
1049
1050
	/**
1051
	 * @SubsonicAPI
1052
	 */
1053
	protected function createBookmark(string $id, int $position, ?string $comment) : array {
1054
		list($type, $entityId) = self::parseBookmarkIdParam($id);
1055
		$this->bookmarkBusinessLayer->addOrUpdate($this->user(), $type, $entityId, $position, $comment);
1056
		return [];
1057
	}
1058
1059
	/**
1060
	 * @SubsonicAPI
1061
	 */
1062
	protected function deleteBookmark(string $id) : array {
1063
		list($type, $entityId) = self::parseBookmarkIdParam($id);
1064
1065
		$bookmark = $this->bookmarkBusinessLayer->findByEntry($type, $entityId, $this->user());
1066
		$this->bookmarkBusinessLayer->delete($bookmark->getId(), $this->user());
1067
1068
		return [];
1069
	}
1070
1071
	/**
1072
	 * @SubsonicAPI
1073
	 */
1074
	protected function getPlayQueue() : 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