SubsonicController::__construct()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 56
Code Lines 28

Duplication

Lines 0
Ratio 0 %

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 1
eloc 28
c 3
b 0
f 0
nc 1
nop 25
dl 0
loc 56
rs 9.472

How to fix   Long Method    Many Parameters   

Long Method

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

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

Commonly applied refactorings include:

Many Parameters

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

There are several approaches to avoid long parameter lists:

1
<?php declare(strict_types=1);
2
3
/**
4
 * ownCloud - Music app
5
 *
6
 * This file is licensed under the Affero General Public License version 3 or
7
 * later. See the COPYING file.
8
 *
9
 * @author Pauli Järvinen <[email protected]>
10
 * @copyright Pauli Järvinen 2019 - 2025
11
 */
12
13
namespace OCA\Music\Controller;
14
15
use OCA\Music\Service\Scrobbler;
16
use OCP\AppFramework\ApiController;
17
use OCP\AppFramework\Http\DataDisplayResponse;
18
use OCP\AppFramework\Http\JSONResponse;
19
use OCP\AppFramework\Http\RedirectResponse;
20
use OCP\AppFramework\Http\Response;
21
use OCP\Files\File;
22
use OCP\Files\Folder;
23
use OCP\Files\Node;
24
use OCP\IConfig;
25
use OCP\IL10N;
26
use OCP\IRequest;
27
use OCP\IUserManager;
28
use OCP\IURLGenerator;
29
30
use OCA\Music\AppFramework\BusinessLayer\BusinessLayerException;
31
use OCA\Music\AppFramework\Core\Logger;
32
use OCA\Music\AppFramework\Utility\MethodAnnotationReader;
33
use OCA\Music\AppFramework\Utility\RequestParameterExtractor;
34
use OCA\Music\AppFramework\Utility\RequestParameterExtractorException;
35
36
use OCA\Music\BusinessLayer\AlbumBusinessLayer;
37
use OCA\Music\BusinessLayer\ArtistBusinessLayer;
38
use OCA\Music\BusinessLayer\BookmarkBusinessLayer;
39
use OCA\Music\BusinessLayer\GenreBusinessLayer;
40
use OCA\Music\BusinessLayer\PlaylistBusinessLayer;
41
use OCA\Music\BusinessLayer\PodcastChannelBusinessLayer;
42
use OCA\Music\BusinessLayer\PodcastEpisodeBusinessLayer;
43
use OCA\Music\BusinessLayer\RadioStationBusinessLayer;
44
use OCA\Music\BusinessLayer\TrackBusinessLayer;
45
46
use OCA\Music\Db\Album;
47
use OCA\Music\Db\Artist;
48
use OCA\Music\Db\Bookmark;
49
use OCA\Music\Db\Genre;
50
use OCA\Music\Db\MatchMode;
51
use OCA\Music\Db\PodcastEpisode;
52
use OCA\Music\Db\SortBy;
53
use OCA\Music\Db\Track;
54
55
use OCA\Music\Http\FileResponse;
56
use OCA\Music\Http\FileStreamResponse;
57
use OCA\Music\Http\XmlResponse;
58
59
use OCA\Music\Middleware\SubsonicException;
60
61
use OCA\Music\Service\AmpacheImageService;
62
use OCA\Music\Service\CoverService;
63
use OCA\Music\Service\DetailsService;
64
use OCA\Music\Service\FileSystemService;
65
use OCA\Music\Service\LastfmService;
66
use OCA\Music\Service\LibrarySettings;
67
use OCA\Music\Service\PodcastService;
68
69
use OCA\Music\Utility\AppInfo;
70
use OCA\Music\Utility\ArrayUtil;
71
use OCA\Music\Utility\HttpUtil;
72
use OCA\Music\Utility\Random;
73
use OCA\Music\Utility\StringUtil;
74
use OCA\Music\Utility\Util;
75
76
class SubsonicController extends ApiController {
77
	private const API_VERSION = '1.16.1';
78
	private const FOLDER_ID_ARTISTS = -1;
79
	private const FOLDER_ID_FOLDERS = -2;
80
81
	private AlbumBusinessLayer $albumBusinessLayer;
82
	private ArtistBusinessLayer $artistBusinessLayer;
83
	private BookmarkBusinessLayer $bookmarkBusinessLayer;
84
	private GenreBusinessLayer $genreBusinessLayer;
85
	private PlaylistBusinessLayer $playlistBusinessLayer;
86
	private PodcastChannelBusinessLayer $podcastChannelBusinessLayer;
87
	private PodcastEpisodeBusinessLayer $podcastEpisodeBusinessLayer;
88
	private RadioStationBusinessLayer $radioStationBusinessLayer;
89
	private TrackBusinessLayer $trackBusinessLayer;
90
	private IURLGenerator $urlGenerator;
91
	private IUserManager $userManager;
92
	private LibrarySettings $librarySettings;
93
	private IL10N $l10n;
94
	private CoverService $coverService;
95
	private DetailsService $detailsService;
96
	private FileSystemService $fileSystemService;
97
	private LastfmService $lastfmService;
98
	private PodcastService $podcastService;
99
	private AmpacheImageService $imageService;
100
	private Random $random;
101
	private Logger $logger;
102
	private IConfig $configManager;
103
	private Scrobbler $scrobbler;
104
	private ?string $userId;
105
	private ?int $keyId;
106
	private array $ignoredArticles;
107
	private string $format;
108
	private ?string $callback;
109
110
	public function __construct(
111
			string $appName,
112
			IRequest $request,
113
			IL10N $l10n,
114
			IURLGenerator $urlGenerator,
115
			IUserManager $userManager,
116
			AlbumBusinessLayer $albumBusinessLayer,
117
			ArtistBusinessLayer $artistBusinessLayer,
118
			BookmarkBusinessLayer $bookmarkBusinessLayer,
119
			GenreBusinessLayer $genreBusinessLayer,
120
			PlaylistBusinessLayer $playlistBusinessLayer,
121
			PodcastChannelBusinessLayer $podcastChannelBusinessLayer,
122
			PodcastEpisodeBusinessLayer $podcastEpisodeBusinessLayer,
123
			RadioStationBusinessLayer $radioStationBusinessLayer,
124
			TrackBusinessLayer $trackBusinessLayer,
125
			LibrarySettings $librarySettings,
126
			CoverService $coverService,
127
			FileSystemService $fileSystemService,
128
			DetailsService $detailsService,
129
			LastfmService $lastfmService,
130
			PodcastService $podcastService,
131
			AmpacheImageService $imageService,
132
			Random $random,
133
			Logger $logger,
134
			\OCP\IConfig $configManager,
135
			Scrobbler $scrobbler
136
	) {
137
		parent::__construct($appName, $request, 'POST, GET', 'Authorization, Content-Type, Accept, X-Requested-With');
138
139
		$this->albumBusinessLayer = $albumBusinessLayer;
140
		$this->artistBusinessLayer = $artistBusinessLayer;
141
		$this->bookmarkBusinessLayer = $bookmarkBusinessLayer;
142
		$this->genreBusinessLayer = $genreBusinessLayer;
143
		$this->playlistBusinessLayer = $playlistBusinessLayer;
144
		$this->podcastChannelBusinessLayer = $podcastChannelBusinessLayer;
145
		$this->podcastEpisodeBusinessLayer = $podcastEpisodeBusinessLayer;
146
		$this->radioStationBusinessLayer = $radioStationBusinessLayer;
147
		$this->trackBusinessLayer = $trackBusinessLayer;
148
		$this->urlGenerator = $urlGenerator;
149
		$this->userManager = $userManager;
150
		$this->l10n = $l10n;
151
		$this->librarySettings = $librarySettings;
152
		$this->coverService = $coverService;
153
		$this->fileSystemService = $fileSystemService;
154
		$this->detailsService = $detailsService;
155
		$this->lastfmService = $lastfmService;
156
		$this->podcastService = $podcastService;
157
		$this->imageService = $imageService;
158
		$this->random = $random;
159
		$this->logger = $logger;
160
		$this->configManager = $configManager;
161
		$this->scrobbler = $scrobbler;
162
		$this->userId = null;
163
		$this->keyId = null;
164
		$this->ignoredArticles = [];
165
		$this->format = 'xml'; // default, should be immediately overridden by SubsonicMiddleware
166
	}
167
168
	/**
169
	 * Called by the middleware to set the response format to be used
170
	 * @param string $format Response format: xml/json/jsonp
171
	 * @param string|null $callback Function name to use if the @a $format is 'jsonp'
172
	 */
173
	public function setResponseFormat(string $format, ?string $callback = null) : void {
174
		$this->format = $format;
175
		$this->callback = $callback;
176
	}
177
178
	/**
179
	 * Called by the middleware once the user credentials have been checked
180
	 */
181
	public function setAuthenticatedUser(string $userId, int $keyId) : void {
182
		$this->userId = $userId;
183
		$this->keyId = $keyId;
184
		$this->ignoredArticles = $this->librarySettings->getIgnoredArticles($userId);
185
	}
186
187
	/**
188
	 * @NoAdminRequired
189
	 * @PublicPage
190
	 * @NoCSRFRequired
191
	 * @NoSameSiteCookieRequired
192
	 * @CORS
193
	 */
194
	public function handleRequest(string $method) : Response {
195
		$this->logger->debug("Subsonic request $method");
196
197
		// Allow calling all methods with or without the postfix ".view"
198
		if (StringUtil::endsWith($method, ".view")) {
199
			$method = \substr($method, 0, -\strlen(".view"));
200
		}
201
202
		// There's only one method allowed without a logged-in user
203
		if ($method !== 'getOpenSubsonicExtensions' && $this->userId === null) {
204
			throw new SubsonicException('User authentication required', 10);
205
		}
206
207
		// Allow calling any functions annotated to be part of the API
208
		if (\method_exists($this, $method)) {
209
			$annotationReader = new MethodAnnotationReader($this, $method);
210
			if ($annotationReader->hasAnnotation('SubsonicAPI')) {
211
				$parameterExtractor = new RequestParameterExtractor($this->request);
212
				try {
213
					$parameterValues = $parameterExtractor->getParametersForMethod($this, $method);
214
				} catch (RequestParameterExtractorException $ex) {
215
					return $this->subsonicErrorResponse(10, $ex->getMessage());
216
				}
217
				$response = \call_user_func_array([$this, $method], $parameterValues);
218
				// The API methods may return either a Response object or an array, which should be converted to Response
219
				if (!($response instanceof Response)) {
220
					$response = $this->subsonicResponse($response);
221
				}
222
				return $response;
223
			}
224
		}
225
226
		$this->logger->warning("Request $method not supported");
227
		return $this->subsonicErrorResponse(0, "Requested action $method is not supported");
228
	}
229
230
	/* -------------------------------------------------------------------------
231
	 * REST API methods
232
	 * -------------------------------------------------------------------------
233
	 */
234
235
	/**
236
	 * @SubsonicAPI
237
	 */
238
	protected function ping() : array {
239
		return [];
240
	}
241
242
	/**
243
	 * @SubsonicAPI
244
	 */
245
	protected function getLicense() : array {
246
		return [
247
			'license' => [
248
				'valid' => true
249
			]
250
		];
251
	}
252
253
	/**
254
	 * @SubsonicAPI
255
	 */
256
	protected function getMusicFolders() : array {
257
		// Only single root folder is supported
258
		return [
259
			'musicFolders' => ['musicFolder' => [
260
				['id' => self::FOLDER_ID_ARTISTS, 'name' => $this->l10n->t('Artists')],
261
				['id' => self::FOLDER_ID_FOLDERS, 'name' => $this->l10n->t('Folders')]
262
			]]
263
		];
264
	}
265
266
	/**
267
	 * @SubsonicAPI
268
	 */
269
	protected function getIndexes(?int $musicFolderId) : array {
270
		if ($musicFolderId === self::FOLDER_ID_FOLDERS) {
271
			return $this->getIndexesForFolders();
272
		} else {
273
			return $this->getIndexesForArtists();
274
		}
275
	}
276
277
	/**
278
	 * @SubsonicAPI
279
	 */
280
	protected function getMusicDirectory(string $id) : array {
281
		if (StringUtil::startsWith($id, 'folder-')) {
282
			return $this->getMusicDirectoryForFolder($id);
283
		} elseif (StringUtil::startsWith($id, 'artist-')) {
284
			return $this->getMusicDirectoryForArtist($id);
285
		} elseif (StringUtil::startsWith($id, 'album-')) {
286
			return $this->getMusicDirectoryForAlbum($id);
287
		} elseif (StringUtil::startsWith($id, 'podcast_channel-')) {
288
			return $this->getMusicDirectoryForPodcastChannel($id);
289
		} else {
290
			throw new SubsonicException("Unsupported id format $id");
291
		}
292
	}
293
294
	/**
295
	 * @SubsonicAPI
296
	 */
297
	protected function getAlbumList(
298
			string $type, ?string $genre, ?int $fromYear, ?int $toYear, int $size=10, int $offset=0) : array {
299
		$albums = $this->albumsForGetAlbumList($type, $genre, $fromYear, $toYear, $size, $offset);
300
		return ['albumList' => [
301
			'album' => \array_map([$this, 'albumToOldApi'], $albums)
302
		]];
303
	}
304
305
	/**
306
	 * @SubsonicAPI
307
	 */
308
	protected function getAlbumList2(
309
			string $type, ?string $genre, ?int $fromYear, ?int $toYear, int $size=10, int $offset=0) : array {
310
		/*
311
		 * According to the API specification, the difference between this and getAlbumList
312
		 * should be that this function would organize albums according the metadata while
313
		 * getAlbumList would organize them by folders. However, we organize by metadata
314
		 * also in getAlbumList, because that's more natural for the Music app and many/most
315
		 * clients do not support getAlbumList2.
316
		 */
317
		$albums = $this->albumsForGetAlbumList($type, $genre, $fromYear, $toYear, $size, $offset);
318
		return ['albumList2' => [
319
			'album' => \array_map([$this, 'albumToNewApi'], $albums)
320
		]];
321
	}
322
323
	/**
324
	 * @SubsonicAPI
325
	 */
326
	protected function getArtists() : array {
327
		return $this->getIndexesForArtists('artists');
328
	}
329
330
	/**
331
	 * @SubsonicAPI
332
	 */
333
	protected function getArtist(string $id) : array {
334
		$artistId = self::ripIdPrefix($id); // get rid of 'artist-' prefix
335
336
		$artist = $this->artistBusinessLayer->find($artistId, $this->user());
337
		$albums = $this->albumBusinessLayer->findAllByArtist($artistId, $this->user());
338
339
		$artistNode = $this->artistToApi($artist);
340
		$artistNode['album'] = \array_map([$this, 'albumToNewApi'], $albums);
341
342
		return ['artist' => $artistNode];
343
	}
344
345
	/**
346
	 * @SubsonicAPI
347
	 */
348
	protected function getArtistInfo(string $id, bool $includeNotPresent=false) : Response {
349
		return $this->doGetArtistInfo('artistInfo', $id, $includeNotPresent);
350
	}
351
352
	/**
353
	 * @SubsonicAPI
354
	 */
355
	protected function getArtistInfo2(string $id, bool $includeNotPresent=false) : Response {
356
		return $this->doGetArtistInfo('artistInfo2', $id, $includeNotPresent);
357
	}
358
359
	/**
360
	 * @SubsonicAPI
361
	 */
362
	protected function getAlbumInfo(string $id) : Response {
363
		return $this->doGetAlbumInfo($id);
364
	}
365
366
	/**
367
	 * @SubsonicAPI
368
	 */
369
	protected function getAlbumInfo2(string $id) : Response {
370
		return $this->doGetAlbumInfo($id);
371
	}
372
373
	/**
374
	 * @SubsonicAPI
375
	 */
376
	protected function getSimilarSongs(string $id, int $count=50) : array {
377
		return $this->doGetSimilarSongs('similarSongs', $id, $count);
378
	}
379
380
	/**
381
	 * @SubsonicAPI
382
	 */
383
	protected function getSimilarSongs2(string $id, int $count=50) : array {
384
		return $this->doGetSimilarSongs('similarSongs2', $id, $count);
385
	}
386
387
	/**
388
	 * @SubsonicAPI
389
	 */
390
	protected function getTopSongs(string $artist, int $count=50) : array {
391
		$tracks = $this->lastfmService->getTopTracks($artist, $this->user(), $count);
392
		return ['topSongs' => [
393
			'song' => $this->tracksToApi($tracks)
394
		]];
395
	}
396
397
	/**
398
	 * @SubsonicAPI
399
	 */
400
	protected function getAlbum(string $id) : array {
401
		$albumId = self::ripIdPrefix($id); // get rid of 'album-' prefix
402
403
		$album = $this->albumBusinessLayer->find($albumId, $this->user());
404
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $this->user());
405
406
		$albumNode = $this->albumToNewApi($album);
407
		$albumNode['song'] = $this->tracksToApi($tracks);
408
		return ['album' => $albumNode];
409
	}
410
411
	/**
412
	 * @SubsonicAPI
413
	 */
414
	protected function getSong(string $id) : array {
415
		$trackId = self::ripIdPrefix($id); // get rid of 'track-' prefix
416
		$track = $this->trackBusinessLayer->find($trackId, $this->user());
417
		return ['song' => $this->trackToApi($track)];
418
	}
419
420
	/**
421
	 * @SubsonicAPI
422
	 */
423
	protected function getRandomSongs(?string $genre, ?string $fromYear, ?string $toYear, int $size=10) : array {
424
		$size = \min($size, 500); // the API spec limits the maximum amount to 500
425
426
		if ($genre !== null) {
427
			$trackPool = $this->findTracksByGenre($genre);
428
		} else {
429
			$trackPool = $this->trackBusinessLayer->findAll($this->user());
430
		}
431
432
		if ($fromYear !== null) {
433
			$trackPool = \array_filter($trackPool, fn($track) => ($track->getYear() !== null && $track->getYear() >= $fromYear));
434
		}
435
436
		if ($toYear !== null) {
437
			$trackPool = \array_filter($trackPool, fn($track) => ($track->getYear() !== null && $track->getYear() <= $toYear));
438
		}
439
440
		$tracks = Random::pickItems($trackPool, $size);
441
442
		return ['randomSongs' => [
443
			'song' => $this->tracksToApi($tracks)
444
		]];
445
	}
446
447
	/**
448
	 * @SubsonicAPI
449
	 */
450
	protected function getCoverArt(string $id, ?int $size) : Response {
451
		list($type, $entityId) = self::parseEntityId($id);
452
		$userId = $this->user();
453
454
		if ($type == 'album') {
455
			$entity = $this->albumBusinessLayer->find($entityId, $userId);
456
		} elseif ($type == 'artist') {
457
			$entity = $this->artistBusinessLayer->find($entityId, $userId);
458
		} elseif ($type == 'podcast_channel') {
459
			$entity = $this->podcastService->getChannel($entityId, $userId, /*$includeEpisodes=*/ false);
460
		} elseif ($type == 'pl') {
461
			$entity = $this->playlistBusinessLayer->find($entityId, $userId);
462
		}
463
464
		if (!empty($entity)) {
465
			$rootFolder = $this->librarySettings->getFolder($userId);
466
			$coverData = $this->coverService->getCover($entity, $userId, $rootFolder, $size);
467
			$response = new FileResponse($coverData);
468
			HttpUtil::setClientCachingDays($response, 30);
469
			return $response;
470
		}
471
472
		return $this->subsonicErrorResponse(70, "entity $id has no cover");
473
	}
474
475
	/**
476
	 * @SubsonicAPI
477
	 */
478
	protected function getLyrics(?string $artist, ?string $title) : array {
479
		$userId = $this->user();
480
		$matches = $this->trackBusinessLayer->findAllByNameArtistOrAlbum($title, $artist, null, $userId);
481
		$matchingCount = \count($matches);
482
483
		if ($matchingCount === 0) {
484
			$this->logger->debug("No matching track for title '$title' and artist '$artist'");
485
			return ['lyrics' => new \stdClass];
486
		} else {
487
			if ($matchingCount > 1) {
488
				$this->logger->debug("Found $matchingCount tracks matching title ".
489
								"'$title' and artist '$artist'; using the first");
490
			}
491
			$track = $matches[0];
492
493
			$artistObj = $this->artistBusinessLayer->find($track->getArtistId(), $userId);
494
			$rootFolder = $this->librarySettings->getFolder($userId);
495
			$lyrics = $this->detailsService->getLyricsAsPlainText($track->getFileId(), $rootFolder);
496
497
			return ['lyrics' => [
498
				'artist' => $artistObj->getNameString($this->l10n),
499
				'title' => $track->getTitle(),
500
				'value' => $lyrics
501
			]];
502
		}
503
	}
504
505
	/**
506
	 * OpenSubsonic extension
507
	 * @SubsonicAPI
508
	 */
509
	protected function getLyricsBySongId(string $id) : array {
510
		$userId = $this->user();
511
		$trackId = self::ripIdPrefix($id); // get rid of 'track-' prefix
512
		$track = $this->trackBusinessLayer->find($trackId, $userId);
513
		$artist = $this->artistBusinessLayer->find($track->getArtistId(), $userId);
514
		$rootFolder = $this->librarySettings->getFolder($userId);
515
		$allLyrics = $this->detailsService->getLyricsAsStructured($track->getFileId(), $rootFolder);
516
517
		return ['lyricsList' => [
518
			'structuredLyrics' => \array_map(function ($lyrics) use ($track, $artist) {
519
				$isSynced = $lyrics['synced'];
520
				return [
521
					'displayArtist' => $artist->getNameString($this->l10n),
522
					'displayTitle' => $track->getTitle(),
523
					'lang' => 'xxx',
524
					'offset' => 0,
525
					'synced' => $isSynced,
526
					'line' => \array_map(function($lineVal, $lineKey) use ($isSynced) {
527
						$line = ['value' => \trim($lineVal)];
528
						if ($isSynced) {
529
							$line['start'] = $lineKey;
530
						};
531
						return $line;
532
					}, $lyrics['lines'], \array_keys($lyrics['lines']))
533
				];
534
			}, $allLyrics)
535
		]];
536
	}
537
538
	/**
539
	 * @SubsonicAPI
540
	 */
541
	protected function stream(string $id) : Response {
542
		// We don't support transcoding, so 'stream' and 'download' act identically
543
		return $this->download($id);
544
	}
545
546
	/**
547
	 * @SubsonicAPI
548
	 */
549
	protected function download(string $id) : Response {
550
		list($type, $entityId) = self::parseEntityId($id);
551
552
		if ($type === 'track') {
553
			$track = $this->trackBusinessLayer->find($entityId, $this->user());
554
			$file = $this->getFilesystemNode($track->getFileId());
555
556
			if ($file instanceof File) {
557
				return new FileStreamResponse($file);
558
			} else {
559
				return $this->subsonicErrorResponse(70, 'file not found');
560
			}
561
		} elseif ($type === 'podcast_episode') {
562
			$episode = $this->podcastService->getEpisode($entityId, $this->user());
563
			if ($episode instanceof PodcastEpisode) {
564
				return new RedirectResponse($episode->getStreamUrl());
0 ignored issues
show
Bug introduced by
It seems like $episode->getStreamUrl() can also be of type null; however, parameter $redirectURL of OCP\AppFramework\Http\Re...Response::__construct() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

564
				return new RedirectResponse(/** @scrutinizer ignore-type */ $episode->getStreamUrl());
Loading history...
565
			} else {
566
				return $this->subsonicErrorResponse(70, 'episode not found');
567
			}
568
		} else {
569
			return $this->subsonicErrorResponse(0, "id of type $type not supported");
570
		}
571
	}
572
573
	/**
574
	 * @SubsonicAPI
575
	 */
576
	protected function search2(string $query, int $artistCount=20, int $artistOffset=0,
577
			int $albumCount=20, int $albumOffset=0, int $songCount=20, int $songOffset=0) : array {
578
		$results = $this->doSearch($query, $artistCount, $artistOffset, $albumCount, $albumOffset, $songCount, $songOffset);
579
		return $this->searchResponse('searchResult2', $results, /*$useNewApi=*/false);
580
	}
581
582
	/**
583
	 * @SubsonicAPI
584
	 */
585
	protected function search3(string $query, int $artistCount=20, int $artistOffset=0,
586
			int $albumCount=20, int $albumOffset=0, int $songCount=20, int $songOffset=0) : array {
587
		$results = $this->doSearch($query, $artistCount, $artistOffset, $albumCount, $albumOffset, $songCount, $songOffset);
588
		return $this->searchResponse('searchResult3', $results, /*$useNewApi=*/true);
589
	}
590
591
	/**
592
	 * @SubsonicAPI
593
	 */
594
	protected function getGenres() : array {
595
		$genres = $this->genreBusinessLayer->findAll($this->user(), SortBy::Name);
596
597
		return ['genres' => [
598
			'genre' => \array_map(fn($genre) => [
599
				'songCount' => $genre->getTrackCount(),
600
				'albumCount' => $genre->getAlbumCount(),
601
				'value' => $genre->getNameString($this->l10n)
602
			], $genres)
603
		]];
604
	}
605
606
	/**
607
	 * @SubsonicAPI
608
	 */
609
	protected function getSongsByGenre(string $genre, int $count=10, int $offset=0) : array {
610
		$tracks = $this->findTracksByGenre($genre, $count, $offset);
611
612
		return ['songsByGenre' => [
613
			'song' => $this->tracksToApi($tracks)
614
		]];
615
	}
616
617
	/**
618
	 * @SubsonicAPI
619
	 */
620
	protected function getPlaylists() : array {
621
		$userId = $this->user();
622
		$playlists = $this->playlistBusinessLayer->findAll($userId);
623
624
		foreach ($playlists as $playlist) {
625
			$playlist->setDuration($this->playlistBusinessLayer->getDuration($playlist->getId(), $userId));
626
		}
627
628
		return ['playlists' => [
629
			'playlist' => \array_map(fn($p) => $p->toSubsonicApi(), $playlists)
630
		]];
631
	}
632
633
	/**
634
	 * @SubsonicAPI
635
	 */
636
	protected function getPlaylist(int $id) : array {
637
		$userId = $this->user();
638
		$playlist = $this->playlistBusinessLayer->find($id, $userId);
639
		$tracks = $this->playlistBusinessLayer->getPlaylistTracks($id, $userId);
640
		$playlist->setDuration(\array_reduce($tracks, function (?int $accuDuration, Track $track) : int {
641
			return (int)$accuDuration + (int)$track->getLength();
642
		}));
643
644
		$playlistNode = $playlist->toSubsonicApi();
645
		$playlistNode['entry'] = $this->tracksToApi($tracks);
646
647
		return ['playlist' => $playlistNode];
648
	}
649
650
	/**
651
	 * @SubsonicAPI
652
	 */
653
	protected function createPlaylist(?string $name, ?string $playlistId, array $songId) : array {
654
		$songIds = \array_map('self::ripIdPrefix', $songId);
655
656
		// If playlist ID has been passed, then this method actually updates an existing list instead of creating a new one.
657
		// 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).
658
		if (!empty($playlistId)) {
659
			$playlistId = (int)$playlistId;
660
		} elseif (!empty($name)) {
661
			$playlist = $this->playlistBusinessLayer->create($name, $this->user());
662
			$playlistId = $playlist->getId();
663
		} else {
664
			throw new SubsonicException('Playlist ID or name must be specified.', 10);
665
		}
666
667
		$this->playlistBusinessLayer->setTracks($songIds, $playlistId, $this->user());
668
669
		return $this->getPlaylist($playlistId);
670
	}
671
672
	/**
673
	 * @SubsonicAPI
674
	 */
675
	protected function updatePlaylist(int $playlistId, ?string $name, ?string $comment, array $songIdToAdd, array $songIndexToRemove) : array {
676
		$songIdsToAdd = \array_map('self::ripIdPrefix', $songIdToAdd);
677
		$songIndicesToRemove = \array_map('intval', $songIndexToRemove);
678
		$userId = $this->user();
679
680
		if (!empty($name)) {
681
			$this->playlistBusinessLayer->rename($name, $playlistId, $userId);
682
		}
683
684
		if ($comment !== null) {
685
			$this->playlistBusinessLayer->setComment($comment, $playlistId, $userId);
686
		}
687
688
		if (!empty($songIndicesToRemove)) {
689
			$this->playlistBusinessLayer->removeTracks($songIndicesToRemove, $playlistId, $userId);
690
		}
691
692
		if (!empty($songIdsToAdd)) {
693
			$this->playlistBusinessLayer->addTracks($songIdsToAdd, $playlistId, $userId);
694
		}
695
696
		return [];
697
	}
698
699
	/**
700
	 * @SubsonicAPI
701
	 */
702
	protected function deletePlaylist(int $id) : array {
703
		$this->playlistBusinessLayer->delete($id, $this->user());
704
		return [];
705
	}
706
707
	/**
708
	 * @SubsonicAPI
709
	 */
710
	protected function getInternetRadioStations() : array {
711
		$stations = $this->radioStationBusinessLayer->findAll($this->user());
712
713
		return ['internetRadioStations' => [
714
			'internetRadioStation' => \array_map(fn($station) => [
715
				'id' => $station->getId(),
716
				'name' => $station->getName() ?: $station->getStreamUrl(),
717
				'streamUrl' => $station->getStreamUrl(),
718
				'homePageUrl' => $station->getHomeUrl()
719
			], $stations)
720
		]];
721
	}
722
723
	/**
724
	 * @SubsonicAPI
725
	 */
726
	protected function createInternetRadioStation(string $streamUrl, string $name, ?string $homepageUrl) : array {
727
		$this->radioStationBusinessLayer->create($this->user(), $name, $streamUrl, $homepageUrl);
728
		return [];
729
	}
730
731
	/**
732
	 * @SubsonicAPI
733
	 */
734
	protected function updateInternetRadioStation(int $id, string $streamUrl, string $name, ?string $homepageUrl) : array {
735
		$this->radioStationBusinessLayer->updateStation($id, $this->user(), $name, $streamUrl, $homepageUrl);
736
		return [];
737
	}
738
739
	/**
740
	 * @SubsonicAPI
741
	 */
742
	protected function deleteInternetRadioStation(int $id) : array {
743
		$this->radioStationBusinessLayer->delete($id, $this->user());
744
		return [];
745
	}
746
747
	/**
748
	 * @SubsonicAPI
749
	 */
750
	protected function getUser(string $username) : array {
751
		$userId = $this->user();
752
		if (\mb_strtolower($username) != \mb_strtolower($userId)) {
753
			throw new SubsonicException("$userId is not authorized to get details for other users.", 50);
754
		}
755
756
		$user = $this->userManager->get($userId);
757
758
		return [
759
			'user' => [
760
				'username' => $userId,
761
				'email' => $user->getEMailAddress(),
762
				'scrobblingEnabled' => true,
763
				'adminRole' => false,
764
				'settingsRole' => false,
765
				'downloadRole' => true,
766
				'uploadRole' => false,
767
				'playlistRole' => true,
768
				'coverArtRole' => false,
769
				'commentRole' => true,
770
				'podcastRole' => true,
771
				'streamRole' => true,
772
				'jukeboxRole' => false,
773
				'shareRole' => false,
774
				'videoConversionRole' => false,
775
				'folder' => [self::FOLDER_ID_ARTISTS, self::FOLDER_ID_FOLDERS],
776
			]
777
		];
778
	}
779
780
	/**
781
	 * @SubsonicAPI
782
	 */
783
	protected function getUsers() : array {
784
		throw new SubsonicException("{$this->user()} is not authorized to get details for other users.", 50);
785
	}
786
787
	/**
788
	 * @SubsonicAPI
789
	 */
790
	protected function getAvatar(string $username) : Response {
791
		$userId = $this->user();
792
		if (\mb_strtolower($username) != \mb_strtolower($userId)) {
793
			throw new SubsonicException("$userId is not authorized to get avatar for other users.", 50);
794
		}
795
796
		$image = $this->userManager->get($userId)->getAvatarImage(150);
797
798
		if ($image !== null) {
799
			return new FileResponse(['content' => $image->data(), 'mimetype' => $image->mimeType()]);
800
		} else {
801
			return $this->subsonicErrorResponse(70, 'user has no avatar');
802
		}
803
	}
804
805
	/**
806
	 * OpenSubsonic extension
807
	 * @SubsonicAPI
808
	 */
809
	protected function tokenInfo() : array {
810
		// This method is intended to be used when API key is used for authentication and the user name is not
811
		// directly available for the client. But it shouldn't hurt to allow calling this regardless of the
812
		// authentication method.
813
		return ['tokenInfo' => ['username' => $this->user()]];
814
	}
815
816
	/**
817
	 * @SubsonicAPI
818
	 */
819
	protected function scrobble(array $id, array $time, bool $submission = true) : array {
820
		// suppress non-submission scrobbles: we retrieve the nowPlaying track from recent plays
821
		// todo: track "now playing" separately
822
		if (!$submission) {
823
			return [];
824
		}
825
826
		if (\count($id) === 0) {
827
			throw new SubsonicException("Required parameter 'id' missing", 10);
828
		}
829
830
		$userId = $this->user();
831
		foreach ($id as $index => $aId) {
832
			list($type, $trackId) = self::parseEntityId($aId);
833
			if ($type === 'track') {
834
				if (isset($time[$index])) {
835
					$timestamp = \substr($time[$index], 0, -3); // cut down from milliseconds to seconds
836
					$timeOfPlay = new \DateTime('@' . $timestamp);
837
				} else {
838
					$timeOfPlay = null;
839
				}
840
				$this->scrobbler->recordTrackPlayed((int)$trackId, $userId, $timeOfPlay);
841
			}
842
		}
843
844
		return [];
845
	}
846
847
	/**
848
	 * @SubsonicAPI
849
	 */
850
	protected function star(array $id, array $albumId, array $artistId) : array {
851
		$targetIds = self::parseStarringParameters($id, $albumId, $artistId);
852
		$userId = $this->user();
853
854
		$this->trackBusinessLayer->setStarred($targetIds['tracks'], $userId);
855
		$this->albumBusinessLayer->setStarred($targetIds['albums'], $userId);
856
		$this->artistBusinessLayer->setStarred($targetIds['artists'], $userId);
857
		$this->podcastChannelBusinessLayer->setStarred($targetIds['podcast_channels'], $userId);
858
		$this->podcastEpisodeBusinessLayer->setStarred($targetIds['podcast_episodes'], $userId);
859
860
		return [];
861
	}
862
863
	/**
864
	 * @SubsonicAPI
865
	 */
866
	protected function unstar(array $id, array $albumId, array $artistId) : array {
867
		$targetIds = self::parseStarringParameters($id, $albumId, $artistId);
868
		$userId = $this->user();
869
870
		$this->trackBusinessLayer->unsetStarred($targetIds['tracks'], $userId);
871
		$this->albumBusinessLayer->unsetStarred($targetIds['albums'], $userId);
872
		$this->artistBusinessLayer->unsetStarred($targetIds['artists'], $userId);
873
		$this->podcastChannelBusinessLayer->unsetStarred($targetIds['podcast_channels'], $userId);
874
		$this->podcastEpisodeBusinessLayer->unsetStarred($targetIds['podcast_episodes'], $userId);
875
876
		return [];
877
	}
878
879
	/**
880
	 * @SubsonicAPI
881
	 */
882
	protected function setRating(string $id, int $rating) : array {
883
		$rating = (int)Util::limit($rating, 0, 5);
884
		list($type, $entityId) = self::parseEntityId($id);
885
886
		switch ($type) {
887
			case 'track':
888
				$bLayer = $this->trackBusinessLayer;
889
				break;
890
			case 'album':
891
				$bLayer = $this->albumBusinessLayer;
892
				break;
893
			case 'artist':
894
				$bLayer = $this->artistBusinessLayer;
895
				break;
896
			case 'podcast_episode':
897
				$bLayer = $this->podcastEpisodeBusinessLayer;
898
				break;
899
			case 'folder':
900
				throw new SubsonicException('Rating folders is not supported', 0);
901
			default:
902
				throw new SubsonicException("Unexpected ID format: $id", 0);
903
		}
904
905
		$bLayer->setRating($entityId, $rating, $this->user());
906
907
		return [];
908
	}
909
910
	/**
911
	 * @SubsonicAPI
912
	 */
913
	protected function getStarred() : array {
914
		$starred = $this->doGetStarred();
915
		return $this->searchResponse('starred', $starred, /*$useNewApi=*/false);
916
	}
917
918
	/**
919
	 * @SubsonicAPI
920
	 */
921
	protected function getStarred2() : array {
922
		$starred = $this->doGetStarred();
923
		return $this->searchResponse('starred2', $starred, /*$useNewApi=*/true);
924
	}
925
926
	/**
927
	 * @SubsonicAPI
928
	 */
929
	protected function getVideos() : array {
930
		// Feature not supported, return an empty list
931
		return [
932
			'videos' => [
933
				'video' => []
934
			]
935
		];
936
	}
937
938
	/**
939
	 * @SubsonicAPI
940
	 */
941
	protected function getPodcasts(?string $id, bool $includeEpisodes = true) : array {
942
		if ($id !== null) {
943
			$id = self::ripIdPrefix($id);
944
			$channel = $this->podcastService->getChannel($id, $this->user(), $includeEpisodes);
945
			if ($channel === null) {
946
				throw new SubsonicException('Requested channel not found', 70);
947
			}
948
			$channels = [$channel];
949
		} else {
950
			$channels = $this->podcastService->getAllChannels($this->user(), $includeEpisodes);
951
		}
952
953
		return [
954
			'podcasts' => [
955
				'channel' => \array_map(fn($c) => $c->toSubsonicApi(), $channels)
956
			]
957
		];
958
	}
959
960
	/**
961
	 * OpenSubsonic extension
962
	 * @SubsonicAPI
963
	 */
964
	protected function getPodcastEpisode(string $id) : array {
965
		$id = self::ripIdPrefix($id);
966
		$episode = $this->podcastService->getEpisode($id, $this->user());
967
968
		if ($episode === null) {
969
			throw new SubsonicException('Requested episode not found', 70);
970
		}
971
972
		return [
973
			'podcastEpisode' => $episode->toSubsonicApi()
974
		];
975
	}
976
977
	/**
978
	 * @SubsonicAPI
979
	 */
980
	protected function getNewestPodcasts(int $count=20) : array {
981
		$episodes = $this->podcastService->getLatestEpisodes($this->user(), $count);
982
983
		return [
984
			'newestPodcasts' => [
985
				'episode' => \array_map(fn($e) => $e->toSubsonicApi(), $episodes)
986
			]
987
		];
988
	}
989
990
	/**
991
	 * @SubsonicAPI
992
	 */
993
	protected function refreshPodcasts() : array {
994
		$this->podcastService->updateAllChannels($this->user());
995
		return [];
996
	}
997
998
	/**
999
	 * @SubsonicAPI
1000
	 */
1001
	protected function createPodcastChannel(string $url) : array {
1002
		$result = $this->podcastService->subscribe($url, $this->user());
1003
1004
		switch ($result['status']) {
1005
			case PodcastService::STATUS_OK:
1006
				return [];
1007
			case PodcastService::STATUS_INVALID_URL:
1008
				throw new SubsonicException("Invalid URL $url", 0);
1009
			case PodcastService::STATUS_INVALID_RSS:
1010
				throw new SubsonicException("The document at URL $url is not a valid podcast RSS feed", 0);
1011
			case PodcastService::STATUS_ALREADY_EXISTS:
1012
				throw new SubsonicException('User already has this podcast channel subscribed', 0);
1013
			default:
1014
				throw new SubsonicException("Unexpected status code {$result['status']}", 0);
1015
		}
1016
	}
1017
1018
	/**
1019
	 * @SubsonicAPI
1020
	 */
1021
	protected function deletePodcastChannel(string $id) : array {
1022
		$id = self::ripIdPrefix($id);
1023
		$status = $this->podcastService->unsubscribe($id, $this->user());
1024
1025
		switch ($status) {
1026
			case PodcastService::STATUS_OK:
1027
				return [];
1028
			case PodcastService::STATUS_NOT_FOUND:
1029
				throw new SubsonicException('Channel to be deleted not found', 70);
1030
			default:
1031
				throw new SubsonicException("Unexpected status code $status", 0);
1032
		}
1033
	}
1034
1035
	/**
1036
	 * @SubsonicAPI
1037
	 */
1038
	protected function getBookmarks() : array {
1039
		$userId = $this->user();
1040
		$bookmarkNodes = [];
1041
		$bookmarks = $this->bookmarkBusinessLayer->findAll($userId);
1042
1043
		foreach ($bookmarks as $bookmark) {
1044
			$node = $bookmark->toSubsonicApi();
1045
			$entryId = $bookmark->getEntryId();
1046
			$type = $bookmark->getType();
1047
1048
			try {
1049
				if ($type === Bookmark::TYPE_TRACK) {
1050
					$track = $this->trackBusinessLayer->find($entryId, $userId);
1051
					$node['entry'] = $this->trackToApi($track);
1052
				} elseif ($type === Bookmark::TYPE_PODCAST_EPISODE) {
1053
					$node['entry'] = $this->podcastEpisodeBusinessLayer->find($entryId, $userId)->toSubsonicApi();
1054
				} else {
1055
					$this->logger->warning("Bookmark {$bookmark->getId()} had unexpected entry type $type");
1056
				}
1057
				$bookmarkNodes[] = $node;
1058
			} catch (BusinessLayerException $e) {
1059
				$this->logger->warning("Bookmarked entry with type $type and id $entryId not found");
1060
			}
1061
		}
1062
1063
		return ['bookmarks' => ['bookmark' => $bookmarkNodes]];
1064
	}
1065
1066
	/**
1067
	 * @SubsonicAPI
1068
	 */
1069
	protected function createBookmark(string $id, int $position, ?string $comment) : array {
1070
		list($type, $entityId) = self::parseBookmarkIdParam($id);
1071
		$this->bookmarkBusinessLayer->addOrUpdate($this->user(), $type, $entityId, $position, $comment);
1072
		return [];
1073
	}
1074
1075
	/**
1076
	 * @SubsonicAPI
1077
	 */
1078
	protected function deleteBookmark(string $id) : array {
1079
		list($type, $entityId) = self::parseBookmarkIdParam($id);
1080
1081
		$bookmark = $this->bookmarkBusinessLayer->findByEntry($type, $entityId, $this->user());
1082
		$this->bookmarkBusinessLayer->delete($bookmark->getId(), $this->user());
1083
1084
		return [];
1085
	}
1086
1087
	/**
1088
	 * @SubsonicAPI
1089
	 */
1090
	protected function getPlayQueue() : array {
1091
		$queueByIndex = $this->getPlayQueueByIndex();
1092
		$queue = $queueByIndex['playQueueByIndex'];
1093
1094
		// Replace the property `currentIndex` with `current`
1095
		if (isset($queue['currentIndex'])) {
1096
			$queue['current'] = $queue['entry'][$queue['currentIndex']]['id'] ?? null;
1097
			unset($queue['currentIndex']);
1098
		}
1099
1100
		return ['playQueue' => $queue];
1101
	}
1102
1103
	/**
1104
	 * OpenSubsonic extension
1105
	 * @SubsonicAPI
1106
	 */
1107
	protected function getPlayQueueByIndex() : array {
1108
		/** @var array|false $playQueue */
1109
		$playQueue = \json_decode($this->configManager->getUserValue($this->user(), $this->appName, 'play_queue', 'false'), true);
1110
1111
		if (!$playQueue) {
1112
			return ['playQueueByIndex' => []];
1113
		}
1114
1115
		// If the queue was saved on a legacy version, then it will still have `current` instead of `currentIndex` => convert if necessary
1116
		if (!isset($playQueue['currentIndex'])) {
1117
			$index = \array_search($playQueue['current'] ?? null, $playQueue['entry']);
1118
			$playQueue['currentIndex'] = ($index === false) ? null : $index;
1119
			unset($playQueue['current']);
1120
		}
1121
1122
		// Convert IDs to full entry items
1123
		$apiEntries = $this->apiEntryIdsToApiEntries($playQueue['entry']);
1124
1125
		// In case any unsupported or non-existing entries were removed by the apiEntryIdsToApiEntries above,
1126
		// the array $apiEntries is now sparse. We need to compact it and adjust the currentIndex accordingly.
1127
		if (\count($apiEntries) != \count($playQueue['entry']) && $playQueue['currentIndex'] !== null) {
1128
			$newIndex = \array_search($playQueue['currentIndex'], \array_keys($apiEntries));
1129
			// Even edgier edge case is when the currentIndex is no longer present after the filtering. In that case, reset the index and position to the beginning.
1130
			if ($newIndex === false) {
1131
				$newIndex = (\count($apiEntries) > 0) ? 0 : null;
1132
				unset($playQueue['position']);
1133
			}
1134
1135
			$playQueue['currentIndex'] = $newIndex;
1136
			$apiEntries = \array_values($apiEntries);
1137
		}
1138
1139
		$playQueue['entry'] = $apiEntries;
1140
		return ['playQueueByIndex' => $playQueue];
1141
	}
1142
1143
	/**
1144
	 * @SubsonicAPI
1145
	 */
1146
	protected function savePlayQueue(array $id, string $c, ?string $current = null, ?int $position = null) : array {
1147
		if ($current === null && !empty($id)) {
1148
			throw new SubsonicException('Parameter `current` is required for a non-empty queue', 10);
1149
		}
1150
1151
		if ($current === null) {
1152
			$currentIdx = null;
1153
		} else {
1154
			$currentIdx = \array_search($current, $id);
1155
			if ($currentIdx === false) {
1156
				throw new SubsonicException('Parameter `current` must be among the listed `id`', 0);
1157
			} else {
1158
				\assert(\is_int($currentIdx)); // technically, $currentIdx could be a string here but that should never happen
1159
			}
1160
		}
1161
1162
		return $this->savePlayQueueByIndex($id, $c, $currentIdx, $position);
1163
	}
1164
1165
	/**
1166
	 * OpenSubsonic extension
1167
	 * @SubsonicAPI
1168
	 */
1169
	protected function savePlayQueueByIndex(array $id, string $c, ?int $currentIndex = null, ?int $position = null) : array {
1170
		if ($currentIndex === null && !empty($id)) {
1171
			throw new SubsonicException('Parameter `currentIndex` is required for a non-empty queue', 10);
1172
		}
1173
1174
		if ($currentIndex < 0 || $currentIndex >= \count($id)) {
1175
			// The error code 10 doesn't actually make sense here but it's mandated by the OpenSubsonic API specification
1176
			throw new SubsonicException('Parameter `currentIndex` must be a valid index within `id`', 10);
1177
		}
1178
1179
		$now = new \DateTime();
1180
		$playQueue = [
1181
			'entry' => $id,
1182
			'changedBy' => $c,
1183
			'position' => $position,
1184
			'currentIndex' => $currentIndex,
1185
			'changed' => Util::formatZuluDateTime($now),
1186
			'username' => $this->user()
1187
		];
1188
1189
		$playQueueJson = \json_encode($playQueue, \JSON_THROW_ON_ERROR);
1190
		$this->configManager->setUserValue($this->userId, $this->appName, 'play_queue', $playQueueJson);
1191
1192
		return [];
1193
	}
1194
1195
	/**
1196
	 * @SubsonicAPI
1197
	 */
1198
	protected function getScanStatus() : array {
1199
		return ['scanStatus' => [
1200
			'scanning' => false,
1201
			'count' => $this->trackBusinessLayer->count($this->user())
1202
		]];
1203
	}
1204
1205
	/**
1206
	 * @SubsonicAPI
1207
	 */
1208
	protected function getNowPlaying() : array {
1209
		// Note: This is documented to return latest play of all users on the server but we don't want to
1210
		// provide access to other people's data => Always return just this user's data.
1211
		$recent = $this->trackBusinessLayer->findRecentPlay($this->user(), 1);
1212
1213
		if (!empty($recent)) {
1214
			$playTime = new \DateTime($recent[0]->getLastPlayed());
1215
			$now = new \DateTime();
1216
			$recent = $this->tracksToApi($recent);
1217
			$recent[0]['username'] = $this->user();
1218
			$recent[0]['minutesAgo'] = (int)(($now->getTimestamp() - $playTime->getTimestamp()) / 60);
1219
			$recent[0]['playerId'] = 0; // dummy
1220
		}
1221
1222
		return ['nowPlaying' => ['entry' => $recent]];
1223
	}
1224
1225
	/**
1226
	 * @SubsonicAPI
1227
	 */
1228
	protected function getOpenSubsonicExtensions() : array {
1229
		return ['openSubsonicExtensions' => [
1230
			[ 'name' => 'apiKeyAuthentication', 'versions' => [1] ],
1231
			[ 'name' => 'formPost', 'versions' => [1] ],
1232
			[ 'name' => 'getPodcastEpisode', 'versions' => [1] ],
1233
			[ 'name' => 'songLyrics', 'versions' => [1] ],
1234
			[ 'name' => 'indexBasedQueue', 'versions' => [1] ]
1235
		]];
1236
	}
1237
1238
	/* -------------------------------------------------------------------------
1239
	 * Helper methods
1240
	 * -------------------------------------------------------------------------
1241
	 */
1242
1243
	/**
1244
	 * @param string|int|null $paramValue
1245
	 */
1246
	private static function ensureParamHasValue(string $paramName, /*mixed*/ $paramValue) : void {
1247
		if ($paramValue === null || $paramValue === '') {
1248
			throw new SubsonicException("Required parameter '$paramName' missing", 10);
1249
		}
1250
	}
1251
1252
	private static function parseBookmarkIdParam(string $id) : array {
1253
		list($typeName, $entityId) = self::parseEntityId($id);
1254
1255
		if ($typeName === 'track') {
1256
			$type = Bookmark::TYPE_TRACK;
1257
		} elseif ($typeName === 'podcast_episode') {
1258
			$type = Bookmark::TYPE_PODCAST_EPISODE;
1259
		} else {
1260
			throw new SubsonicException("Unsupported ID format $id", 0);
1261
		}
1262
1263
		return [$type, $entityId];
1264
	}
1265
1266
	/**
1267
	 * Parse parameters used in the `star` and `unstar` API methods
1268
	 */
1269
	private static function parseStarringParameters(array $ids, array $albumIds, array $artistIds) : array {
1270
		// album IDs from newer clients
1271
		$albumIds = \array_map('self::ripIdPrefix', $albumIds);
1272
1273
		// artist IDs from newer clients
1274
		$artistIds = \array_map('self::ripIdPrefix', $artistIds);
1275
1276
		// Song IDs from newer clients and song/folder/album/artist IDs from older clients are all packed in $ids.
1277
		// Also podcast IDs may come there; that is not documented part of the API but at least DSub does that.
1278
1279
		$trackIds = [];
1280
		$channelIds = [];
1281
		$episodeIds = [];
1282
1283
		foreach ($ids as $prefixedId) {
1284
			list($type, $id) = self::parseEntityId($prefixedId);
1285
1286
			if ($type == 'track') {
1287
				$trackIds[] = $id;
1288
			} elseif ($type == 'album') {
1289
				$albumIds[] = $id;
1290
			} elseif ($type == 'artist') {
1291
				$artistIds[] = $id;
1292
			} elseif ($type == 'podcast_channel') {
1293
				$channelIds[] = $id;
1294
			} elseif ($type == 'podcast_episode') {
1295
				$episodeIds[] = $id;
1296
			} elseif ($type == 'folder') {
1297
				throw new SubsonicException('Starring folders is not supported', 0);
1298
			} else {
1299
				throw new SubsonicException("Unexpected ID format: $prefixedId", 0);
1300
			}
1301
		}
1302
1303
		return [
1304
			'tracks' => $trackIds,
1305
			'albums' => $albumIds,
1306
			'artists' => $artistIds,
1307
			'podcast_channels' => $channelIds,
1308
			'podcast_episodes' => $episodeIds
1309
		];
1310
	}
1311
1312
	private function user() : string {
1313
		if ($this->userId === null) {
1314
			throw new SubsonicException('User authentication required', 10);
1315
		}
1316
		return $this->userId;
1317
	}
1318
1319
	private function getFilesystemNode(int $id) : Node {
1320
		$rootFolder = $this->librarySettings->getFolder($this->user());
1321
		$nodes = $rootFolder->getById($id);
1322
1323
		if (\count($nodes) != 1) {
1324
			throw new SubsonicException('file not found', 70);
1325
		}
1326
1327
		return $nodes[0];
1328
	}
1329
1330
	private function nameWithoutArticle(?string $name) : ?string {
1331
		return StringUtil::splitPrefixAndBasename($name, $this->ignoredArticles)['basename'];
1332
	}
1333
1334
	private static function getIndexingChar(?string $name) : string {
1335
		// For unknown artists, use '?'
1336
		$char = '?';
1337
1338
		if (!empty($name)) {
1339
			$char = \mb_convert_case(\mb_substr($name, 0, 1), MB_CASE_UPPER);
1340
		}
1341
		// Bundle all numeric characters together
1342
		if (\is_numeric($char)) {
1343
			$char = '#';
1344
		}
1345
1346
		return $char;
1347
	}
1348
1349
	private function getSubFoldersAndTracks(Folder $folder) : array {
1350
		$nodes = $folder->getDirectoryListing();
1351
		$subFolders = \array_filter($nodes, fn($n) =>
1352
			($n instanceof Folder) && $this->librarySettings->pathBelongsToMusicLibrary($n->getPath(), $this->user())
1353
		);
1354
1355
		$tracks = $this->trackBusinessLayer->findAllByFolder($folder->getId(), $this->user());
1356
1357
		return [$subFolders, $tracks];
1358
	}
1359
1360
	private function getIndexesForFolders() : array {
1361
		$rootFolder = $this->librarySettings->getFolder($this->user());
1362
1363
		list($subFolders, $tracks) = $this->getSubFoldersAndTracks($rootFolder);
1364
1365
		$indexes = [];
1366
		foreach ($subFolders as $folder) {
1367
			$sortName = $this->nameWithoutArticle($folder->getName());
1368
			$indexes[self::getIndexingChar($sortName)][] = [
1369
				'sortName' => $sortName,
1370
				'artist' => [
1371
					'name' => $folder->getName(),
1372
					'id' => 'folder-' . $folder->getId()
1373
				]
1374
			];
1375
		}
1376
		\ksort($indexes, SORT_LOCALE_STRING);
1377
1378
		$folders = [];
1379
		foreach ($indexes as $indexChar => $bucketArtists) {
1380
			ArrayUtil::sortByColumn($bucketArtists, 'sortName');
1381
			$folders[] = ['name' => $indexChar, 'artist' => \array_column($bucketArtists, 'artist')];
1382
		}
1383
1384
		return ['indexes' => [
1385
			'ignoredArticles' => \implode(' ', $this->ignoredArticles),
1386
			'index' => $folders,
1387
			'child' => $this->tracksToApi($tracks)
1388
		]];
1389
	}
1390
1391
	private function getMusicDirectoryForFolder(string $id) : array {
1392
		$folderId = self::ripIdPrefix($id); // get rid of 'folder-' prefix
1393
		$folder = $this->getFilesystemNode($folderId);
1394
1395
		if (!($folder instanceof Folder)) {
1396
			throw new SubsonicException("$id is not a valid folder", 70);
1397
		}
1398
1399
		list($subFolders, $tracks) = $this->getSubFoldersAndTracks($folder);
1400
1401
		$children = \array_merge(
1402
			\array_map([$this, 'folderToApi'], $subFolders),
1403
			$this->tracksToApi($tracks)
1404
		);
1405
1406
		$content = [
1407
			'directory' => [
1408
				'id' => $id,
1409
				'name' => $folder->getName(),
1410
				'child' => $children
1411
			]
1412
		];
1413
1414
		// Parent folder ID is included if and only if the parent folder is not the top level
1415
		$rootFolderId = $this->librarySettings->getFolder($this->user())->getId();
1416
		$parentFolderId = $folder->getParent()->getId();
1417
		if ($rootFolderId != $parentFolderId) {
1418
			$content['parent'] = 'folder-' . $parentFolderId;
1419
		}
1420
1421
		return $content;
1422
	}
1423
1424
	private function getIndexesForArtists(string $rootElementName = 'indexes') : array {
1425
		$artists = $this->artistBusinessLayer->findAllHavingAlbums($this->user(), SortBy::Name);
1426
1427
		$indexes = [];
1428
		foreach ($artists as $artist) {
1429
			$sortName = $this->nameWithoutArticle($artist->getName());
1430
			$indexes[self::getIndexingChar($sortName)][] = ['sortName' => $sortName, 'artist' => $this->artistToApi($artist)];
1431
		}
1432
		\ksort($indexes, SORT_LOCALE_STRING);
1433
1434
		$result = [];
1435
		foreach ($indexes as $indexChar => $bucketArtists) {
1436
			ArrayUtil::sortByColumn($bucketArtists, 'sortName');
1437
			$result[] = ['name' => $indexChar, 'artist' => \array_column($bucketArtists, 'artist')];
1438
		}
1439
1440
		return [$rootElementName => [
1441
			'ignoredArticles' => \implode(' ', $this->ignoredArticles),
1442
			'index' => $result
1443
		]];
1444
	}
1445
1446
	private function getMusicDirectoryForArtist(string $id) : array {
1447
		$artistId = self::ripIdPrefix($id); // get rid of 'artist-' prefix
1448
1449
		$artist = $this->artistBusinessLayer->find($artistId, $this->user());
1450
		$albums = $this->albumBusinessLayer->findAllByArtist($artistId, $this->user());
1451
1452
		return [
1453
			'directory' => [
1454
				'id' => $id,
1455
				'name' => $artist->getNameString($this->l10n),
1456
				'child' => \array_map([$this, 'albumToOldApi'], $albums)
1457
			]
1458
		];
1459
	}
1460
1461
	private function getMusicDirectoryForAlbum(string $id) : array {
1462
		$albumId = self::ripIdPrefix($id); // get rid of 'album-' prefix
1463
1464
		$album = $this->albumBusinessLayer->find($albumId, $this->user());
1465
		$albumName = $album->getNameString($this->l10n);
1466
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $this->user());
1467
1468
		return [
1469
			'directory' => [
1470
				'id' => $id,
1471
				'parent' => 'artist-' . $album->getAlbumArtistId(),
1472
				'name' => $albumName,
1473
				'child' => $this->tracksToApi($tracks)
1474
			]
1475
		];
1476
	}
1477
1478
	private function getMusicDirectoryForPodcastChannel(string $id) : array {
1479
		$channelId = self::ripIdPrefix($id); // get rid of 'podcast_channel-' prefix
1480
		$channel = $this->podcastService->getChannel($channelId, $this->user(), /*$includeEpisodes=*/ true);
1481
1482
		if ($channel === null) {
1483
			throw new SubsonicException("Podcast channel $channelId not found", 0);
1484
		}
1485
1486
		return [
1487
			'directory' => [
1488
				'id' => $id,
1489
				'name' => $channel->getTitle(),
1490
				'child' => \array_map(fn($e) => $e->toSubsonicApi(), $channel->getEpisodes() ?? [])
1491
			]
1492
		];
1493
	}
1494
1495
	private function folderToApi(Folder $folder) : array {
1496
		return [
1497
			'id' => 'folder-' . $folder->getId(),
1498
			'title' => $folder->getName(),
1499
			'isDir' => true
1500
		];
1501
	}
1502
1503
	private function artistToApi(Artist $artist) : array {
1504
		$id = $artist->getId();
1505
		$result = [
1506
			'name' => $artist->getNameString($this->l10n),
1507
			'id' => $id ? ('artist-' . $id) : '-1', // getArtistInfo may show artists without ID
1508
			'albumCount' => $id ? $this->albumBusinessLayer->countByArtist($id) : 0,
1509
			'starred' => Util::formatZuluDateTime($artist->getStarred()),
1510
			'userRating' => $artist->getRating() ?: null,
1511
			'averageRating' => $artist->getRating() ?: null,
1512
			'sortName' => $this->nameWithoutArticle($artist->getName()) ?? '', // OpenSubsonic
1513
			'mediaType' => 'artist', // OpenSubsonic, only specified for the "old" API but we don't separate the APIs here
1514
		];
1515
1516
		if (!empty($artist->getCoverFileId())) {
1517
			$result['coverArt'] = $result['id'];
1518
			$result['artistImageUrl'] = $this->artistImageUrl($id);
1519
		}
1520
1521
		return $result;
1522
	}
1523
1524
	/**
1525
	 * The "old API" format is used e.g. in getMusicDirectory and getAlbumList
1526
	 */
1527
	private function albumToOldApi(Album $album) : array {
1528
		$result = $this->albumCommonApiFields($album);
1529
1530
		$result['parent'] = 'artist-' . $album->getAlbumArtistId();
1531
		$result['title'] = $album->getNameString($this->l10n);
1532
		$result['isDir'] = true;
1533
		$result['mediaType'] = 'album'; // OpenSubsonic
1534
1535
		return $result;
1536
	}
1537
1538
	/**
1539
	 * The "new API" format is used e.g. in getAlbum and getAlbumList2
1540
	 */
1541
	private function albumToNewApi(Album $album) : array {
1542
		$result = $this->albumCommonApiFields($album);
1543
1544
		$result['artistId'] = 'artist-' . $album->getAlbumArtistId();
1545
		$result['name'] = $album->getNameString($this->l10n);
1546
		$result['songCount'] = $this->trackBusinessLayer->countByAlbum($album->getId());
1547
		$result['duration'] = $this->trackBusinessLayer->totalDurationOfAlbum($album->getId());
1548
1549
		return $result;
1550
	}
1551
1552
	private function albumCommonApiFields(Album $album) : array {
1553
		$genres = \array_map(
1554
			fn(Genre $genre) => $genre->getNameString($this->l10n),
1555
			$album->getGenres() ?? []
1556
		);
1557
1558
		return [
1559
			'id' => 'album-' . $album->getId(),
1560
			'artist' => $album->getAlbumArtistNameString($this->l10n),
1561
			'artists' => \array_map(fn($artist) => [
1562
				'id' => 'artist-' . $artist->getId(),
1563
				'name' => $artist->getNameString($this->l10n)
1564
			], $album->getArtists() ?? []),
1565
			'created' => Util::formatZuluDateTime($album->getCreated()),
1566
			'coverArt' => empty($album->getCoverFileId()) ? null : 'album-' . $album->getId(),
1567
			'starred' => Util::formatZuluDateTime($album->getStarred()),
1568
			'userRating' => $album->getRating() ?: null,
1569
			'averageRating' => $album->getRating() ?: null,
1570
			'year' => $album->yearToAPI(),
1571
			'genre' => \implode(', ', $genres) ?: null,
1572
			'genres' => \array_map(fn($name) => ['name' => $name], $genres), // OpenSubsonic
1573
			'sortName' => $this->nameWithoutArticle($album->getName()) ?? '', // OpenSubsonic
1574
		];
1575
	}
1576
1577
	/**
1578
	 * @param Track[] $tracks
1579
	 */
1580
	private function tracksToApi(array $tracks) : array {
1581
		$userId = $this->user();
1582
		$musicFolder = $this->librarySettings->getFolder($userId);
1583
		$this->fileSystemService->injectFolderPathsToTracks($tracks, $userId, $musicFolder);
1584
		$this->albumBusinessLayer->injectAlbumsToTracks($tracks, $userId);
1585
		return \array_map(fn($t) => $t->toSubsonicApi($this->l10n, $this->ignoredArticles), $tracks);
1586
	}
1587
1588
	private function trackToApi(Track $track) : array {
1589
		return $this->tracksToApi([$track])[0];
1590
	}
1591
1592
	/**
1593
	 * @param PodcastEpisode[] $episodes
1594
	 */
1595
	private function podcastEpisodesToApi(array $episodes) : array {
1596
		return \array_map(fn(PodcastEpisode $p) => $p->toSubsonicApi(), $episodes);
1597
	}
1598
1599
	/**
1600
	 * @param string[] $entryIds A possibly mixed array of IDs like "track-123" or "podcast_episode-45"
1601
	 * @return array Entries in the API format. The array may be sparse, in case there were any unsupported/invalid IDs.
1602
	 */
1603
	private function apiEntryIdsToApiEntries(array $entryIds) : array {
1604
		$parsedEntries = \array_map([self::class, 'parseEntityId'], $entryIds);
1605
1606
		$typeHandlers = [
1607
			[
1608
				'track',
1609
				[$this->trackBusinessLayer, 'findById'],
1610
				[$this, 'tracksToApi']
1611
			],
1612
			[
1613
				'podcast_episode',
1614
				[$this->podcastEpisodeBusinessLayer, 'findById'],
1615
				[$this, 'podcastEpisodesToApi']
1616
			]
1617
		];
1618
1619
		/** @var array{'track': Track[], 'podcast_episode': PodcastEpisode[]} $apiEntriesLut */
1620
		$apiEntriesLut = \array_merge([], ...array_map(
1621
			function ($handlers) use ($parsedEntries) {
1622
				[$type, $lookupFn, $toApiFn] = $handlers;
1623
				$typeEntryIds = \array_map(
1624
					fn ($entry) => $entry[1],
1625
					\array_filter($parsedEntries, fn ($parsedEntry) => $parsedEntry[0] === $type)
1626
				);
1627
1628
				$entryInstances = $lookupFn($typeEntryIds, $this->user());
1629
1630
				return [
1631
					$type => $toApiFn(ArrayUtil::createIdLookupTable($entryInstances))
1632
				];
1633
			},
1634
			$typeHandlers
1635
		));
1636
1637
		return \array_filter(\array_map(
1638
			function ($parsedEntry) use ($apiEntriesLut) {
1639
				[$type, $id] = $parsedEntry;
1640
				return $apiEntriesLut[$type][$id] ?? false;
1641
			},
1642
			$parsedEntries
1643
		));
1644
	}
1645
1646
	/**
1647
	 * Common logic for getAlbumList and getAlbumList2
1648
	 * @return Album[]
1649
	 */
1650
	private function albumsForGetAlbumList(
1651
			string $type, ?string $genre, ?int $fromYear, ?int $toYear, int $size, int $offset) : array {
1652
		$size = \min($size, 500); // the API spec limits the maximum amount to 500
1653
		$userId = $this->user();
1654
1655
		$albums = [];
1656
1657
		switch ($type) {
1658
			case 'random':
1659
				$allAlbums = $this->albumBusinessLayer->findAll($userId);
1660
				$indices = $this->random->getIndices(\count($allAlbums), $offset, $size, $userId, 'subsonic_albums');
1661
				$albums = ArrayUtil::multiGet($allAlbums, $indices);
1662
				break;
1663
			case 'starred':
1664
				$albums = $this->albumBusinessLayer->findAllStarred($userId, $size, $offset);
1665
				break;
1666
			case 'alphabeticalByName':
1667
				$albums = $this->albumBusinessLayer->findAll($userId, SortBy::Name, $size, $offset);
1668
				break;
1669
			case 'alphabeticalByArtist':
1670
				$albums = $this->albumBusinessLayer->findAll($userId, SortBy::Parent, $size, $offset);
1671
				break;
1672
			case 'byGenre':
1673
				self::ensureParamHasValue('genre', $genre);
1674
				$albums = $this->findAlbumsByGenre($genre, $size, $offset);
1675
				break;
1676
			case 'byYear':
1677
				self::ensureParamHasValue('fromYear', $fromYear);
1678
				self::ensureParamHasValue('toYear', $toYear);
1679
				$albums = $this->albumBusinessLayer->findAllByYearRange($fromYear, $toYear, $userId, $size, $offset);
1680
				break;
1681
			case 'newest':
1682
				$albums = $this->albumBusinessLayer->findAll($userId, SortBy::Newest, $size, $offset);
1683
				break;
1684
			case 'frequent':
1685
				$albums = $this->albumBusinessLayer->findFrequentPlay($userId, $size, $offset);
1686
				break;
1687
			case 'recent':
1688
				$albums = $this->albumBusinessLayer->findRecentPlay($userId, $size, $offset);
1689
				break;
1690
			case 'highest':
1691
				$albums = $this->albumBusinessLayer->findAllRated($userId, $size, $offset);
1692
				break;
1693
			default:
1694
				$this->logger->debug("Album list type '$type' is not supported");
1695
				break;
1696
		}
1697
1698
		return $albums;
1699
	}
1700
1701
	/**
1702
	 * Given any entity ID like 'track-123' or 'album-2' or 'artist-3' or 'folder-4', return the matching
1703
	 * numeric artist identifier if possible (may be e.g. performer of the track or album, or an artist
1704
	 * with a name matching the folder name)
1705
	 */
1706
	private function getArtistIdFromEntityId(string $entityId) : ?int {
1707
		list($type, $id) = self::parseEntityId($entityId);
1708
		$userId = $this->user();
1709
1710
		switch ($type) {
1711
			case 'artist':
1712
				return $id;
1713
			case 'album':
1714
				return $this->albumBusinessLayer->find($id, $userId)->getAlbumArtistId();
1715
			case 'track':
1716
				return $this->trackBusinessLayer->find($id, $userId)->getArtistId();
1717
			case 'folder':
1718
				$folder = $this->librarySettings->getFolder($userId)->getById($id)[0] ?? null;
1719
				if ($folder !== null) {
1720
					$artist = $this->artistBusinessLayer->findAllByName($folder->getName(), $userId)[0] ?? null;
1721
					if ($artist !== null) {
1722
						return $artist->getId();
1723
					}
1724
				}
1725
				break;
1726
		}
1727
1728
		return null;
1729
	}
1730
1731
	/**
1732
	 * Common logic for getArtistInfo and getArtistInfo2
1733
	 */
1734
	private function doGetArtistInfo(string $rootName, string $id, bool $includeNotPresent) : Response {
1735
		$content = [];
1736
1737
		$userId = $this->user();
1738
		$artistId = $this->getArtistIdFromEntityId($id);
1739
		if ($artistId !== null) {
1740
			$info = $this->lastfmService->getArtistInfo($artistId, $userId);
1741
1742
			if (isset($info['artist'])) {
1743
				$content = [
1744
					'biography' => $info['artist']['bio']['summary'],
1745
					'lastFmUrl' => $info['artist']['url'],
1746
					'musicBrainzId' => $info['artist']['mbid'] ?? null
1747
				];
1748
1749
				$similarArtists = $this->lastfmService->getSimilarArtists($artistId, $userId, $includeNotPresent);
1750
				$content['similarArtist'] = \array_map([$this, 'artistToApi'], $similarArtists);
1751
			}
1752
1753
			$artist = $this->artistBusinessLayer->find($artistId, $userId);
1754
			if ($artist->getCoverFileId() !== null) {
1755
				$content['largeImageUrl'] = [$this->artistImageUrl($artistId)];
1756
			}
1757
		}
1758
1759
		// This method is unusual in how it uses non-attribute elements in the response. On the other hand,
1760
		// all the details of the <similarArtist> elements are rendered as attributes. List those separately.
1761
		$attributeKeys = ['name', 'id', 'albumCount', 'coverArt', 'artistImageUrl', 'starred'];
1762
1763
		return $this->subsonicResponse([$rootName => $content], $attributeKeys);
1764
	}
1765
1766
	/**
1767
	 * Given any entity ID like 'track-123' or 'album-2' or 'folder-4', return the matching numeric
1768
	 * album identifier if possible (may be e.g. host album of the track or album with a name
1769
	 * matching the folder name)
1770
	 */
1771
	private function getAlbumIdFromEntityId(string $entityId) : ?int {
1772
		list($type, $id) = self::parseEntityId($entityId);
1773
		$userId = $this->user();
1774
1775
		switch ($type) {
1776
			case 'album':
1777
				return $id;
1778
			case 'track':
1779
				return $this->trackBusinessLayer->find($id, $userId)->getAlbumId();
1780
			case 'folder':
1781
				$folder = $this->librarySettings->getFolder($userId)->getById($id)[0] ?? null;
1782
				if ($folder !== null) {
1783
					$album = $this->albumBusinessLayer->findAllByName($folder->getName(), $userId)[0] ?? null;
1784
					if ($album !== null) {
1785
						return $album->getId();
1786
					}
1787
				}
1788
				break;
1789
		}
1790
1791
		return null;
1792
	}
1793
1794
	/**
1795
	 * Common logic for getAlbumInfo and getAlbumInfo2
1796
	 */
1797
	private function doGetAlbumInfo(string $id) : Response {
1798
		$albumId = $this->getAlbumIdFromEntityId($id);
1799
		if ($albumId === null) {
1800
			throw new SubsonicException("Unexpected ID format: $id", 0);
1801
		}
1802
		
1803
		$info = $this->lastfmService->getAlbumInfo($albumId, $this->user());
1804
1805
		if (isset($info['album'])) {
1806
			$content = [
1807
				'notes' => $info['album']['wiki']['summary'] ?? null,
1808
				'lastFmUrl' => $info['album']['url'],
1809
				'musicBrainzId' => $info['album']['mbid'] ?? null
1810
			];
1811
1812
			foreach ($info['album']['image'] ?? [] as $imageInfo) {
1813
				if (!empty($imageInfo['size'])) {
1814
					$content[$imageInfo['size'] . 'ImageUrl'] = $imageInfo['#text'];
1815
				}
1816
			}
1817
		} else {
1818
			$content = new \stdClass;
1819
		}
1820
1821
		// This method is unusual in how it uses non-attribute elements in the response.
1822
		return $this->subsonicResponse(['albumInfo' => $content], []);
1823
	}
1824
1825
	/**
1826
	 * Common logic for getSimilarSongs and getSimilarSongs2
1827
	 */
1828
	private function doGetSimilarSongs(string $rootName, string $id, int $count) : array {
1829
		$userId = $this->user();
1830
1831
		if (StringUtil::startsWith($id, 'artist')) {
1832
			$artistId = self::ripIdPrefix($id);
1833
		} elseif (StringUtil::startsWith($id, 'album')) {
1834
			$albumId = self::ripIdPrefix($id);
1835
			$artistId = $this->albumBusinessLayer->find($albumId, $userId)->getAlbumArtistId();
1836
		} elseif (StringUtil::startsWith($id, 'track')) {
1837
			$trackId = self::ripIdPrefix($id);
1838
			$artistId = $this->trackBusinessLayer->find($trackId, $userId)->getArtistId();
1839
		} else {
1840
			throw new SubsonicException("Id $id has a type not supported on getSimilarSongs", 0);
1841
		}
1842
1843
		$artists = $this->lastfmService->getSimilarArtists($artistId, $userId);
1844
		$artists[] = $this->artistBusinessLayer->find($artistId, $userId);
1845
1846
		// Get all songs by the found artists
1847
		$songs = [];
1848
		foreach ($artists as $artist) {
1849
			$songs = \array_merge($songs, $this->trackBusinessLayer->findAllByArtist($artist->getId(), $userId));
1850
		}
1851
1852
		// Randomly select the desired number of songs
1853
		$songs = $this->random->pickItems($songs, $count);
1854
1855
		return [$rootName => [
1856
			'song' => $this->tracksToApi($songs)
1857
		]];
1858
	}
1859
1860
	/**
1861
	 * Common logic for search2 and search3
1862
	 * @return array with keys 'artists', 'albums', and 'tracks'
1863
	 */
1864
	private function doSearch(string $query, int $artistCount, int $artistOffset,
1865
			int $albumCount, int $albumOffset, int $songCount, int $songOffset) : array {
1866
1867
		$userId = $this->user();
1868
1869
		// The searches support '*' as a wildcard. Convert those to the SQL wildcard '%' as that's what the business layer searches support.
1870
		$query = \str_replace('*', '%', $query);
1871
1872
		return [
1873
			'artists' => $this->artistBusinessLayer->findAllByName($query, $userId, MatchMode::Substring, $artistCount, $artistOffset),
1874
			'albums' => $this->albumBusinessLayer->findAllByNameRecursive($query, $userId, $albumCount, $albumOffset),
1875
			'tracks' => $this->trackBusinessLayer->findAllByNameRecursive($query, $userId, $songCount, $songOffset)
1876
		];
1877
	}
1878
1879
	/**
1880
	 * Common logic for getStarred and getStarred2
1881
	 */
1882
	private function doGetStarred() : array {
1883
		$userId = $this->user();
1884
		return [
1885
			'artists' => $this->artistBusinessLayer->findAllStarred($userId),
1886
			'albums' => $this->albumBusinessLayer->findAllStarred($userId),
1887
			'tracks' => $this->trackBusinessLayer->findAllStarred($userId)
1888
		];
1889
	}
1890
1891
	/**
1892
	 * Common response building logic for search2, search3, getStarred, and getStarred2
1893
	 * @param string $title Name of the main node in the response message
1894
	 * @param array $results Search results with keys 'artists', 'albums', and 'tracks'
1895
	 * @param bool $useNewApi Set to true for search3 and getStarred2. There is a difference
1896
	 *                        in the formatting of the album nodes.
1897
	 */
1898
	private function searchResponse(string $title, array $results, bool $useNewApi) : array {
1899
		$albumMapFunc = $useNewApi ? 'albumToNewApi' : 'albumToOldApi';
1900
1901
		return [$title => [
1902
			'artist' => \array_map([$this, 'artistToApi'], $results['artists']),
1903
			'album' => \array_map([$this, $albumMapFunc], $results['albums']),
1904
			'song' => $this->tracksToApi($results['tracks'])
1905
		]];
1906
	}
1907
1908
	/**
1909
	 * Find tracks by genre name
1910
	 * @return Track[]
1911
	 */
1912
	private function findTracksByGenre(string $genreName, ?int $limit=null, ?int $offset=null) : array {
1913
		$genre = $this->findGenreByName($genreName);
1914
1915
		if ($genre) {
1916
			return $this->trackBusinessLayer->findAllByGenre($genre->getId(), $this->user(), $limit, $offset);
1917
		} else {
1918
			return [];
1919
		}
1920
	}
1921
1922
	/**
1923
	 * Find albums by genre name
1924
	 * @return Album[]
1925
	 */
1926
	private function findAlbumsByGenre(string $genreName, ?int $limit=null, ?int $offset=null) : array {
1927
		$genre = $this->findGenreByName($genreName);
1928
1929
		if ($genre) {
1930
			return $this->albumBusinessLayer->findAllByGenre($genre->getId(), $this->user(), $limit, $offset);
1931
		} else {
1932
			return [];
1933
		}
1934
	}
1935
1936
	private function findGenreByName(string $name) : ?Genre {
1937
		$genreArr = $this->genreBusinessLayer->findAllByName($name, $this->user());
1938
		if (\count($genreArr) == 0 && $name == Genre::unknownNameString($this->l10n)) {
1939
			$genreArr = $this->genreBusinessLayer->findAllByName('', $this->user());
1940
		}
1941
		return \count($genreArr) ? $genreArr[0] : null;
1942
	}
1943
1944
	private function artistImageUrl(int $id) : string {
1945
		\assert($this->keyId !== null, 'function should not get called without authenticated user');
1946
		$token = $this->imageService->getToken('artist', $id, $this->keyId);
1947
		return $this->urlGenerator->linkToRouteAbsolute(
1948
			'music.ampacheImage.image',
1949
			['object_type' => 'artist', 'object_id' => $id, 'token' => $token, 'size' => CoverService::DO_NOT_CROP_OR_SCALE]
1950
		);
1951
	}
1952
1953
	/**
1954
	 * Given a prefixed ID like 'artist-123' or 'track-45', return the string part and the numeric part.
1955
	 * @throws SubsonicException if the \a $id doesn't follow the expected pattern
1956
	 */
1957
	private static function parseEntityId(string $id) : array {
1958
		$parts = \explode('-', $id);
1959
		if (\count($parts) !== 2) {
1960
			throw new SubsonicException("Unexpected ID format: $id", 0);
1961
		}
1962
		$parts[1] = (int)$parts[1];
1963
		return $parts;
1964
	}
1965
1966
	/**
1967
	 * Given a prefixed ID like 'artist-123' or 'track-45', return just the numeric part.
1968
	 */
1969
	private static function ripIdPrefix(string $id) : int {
1970
		return self::parseEntityId($id)[1];
1971
	}
1972
1973
	/**
1974
	 * @param bool|string[] $useAttributes
1975
	 */
1976
	private function subsonicResponse(array $content, /*mixed*/ $useAttributes=true, string $status = 'ok') : Response {
1977
		$content['status'] = $status;
1978
		$content['version'] = self::API_VERSION;
1979
		$content['type'] = AppInfo::getFullName();
1980
		$content['serverVersion'] = AppInfo::getVersion();
1981
		$content['openSubsonic'] = true;
1982
		$responseData = ['subsonic-response' => ArrayUtil::rejectRecursive($content, 'is_null')];
1983
1984
		if ($this->format == 'json') {
1985
			$response = new JSONResponse($responseData);
1986
		} elseif ($this->format == 'jsonp') {
1987
			$responseData = \json_encode($responseData);
1988
			$response = new DataDisplayResponse("{$this->callback}($responseData);");
1989
			$response->addHeader('Content-Type', 'text/javascript; charset=UTF-8');
1990
		} else {
1991
			if (\is_array($useAttributes)) {
1992
				$useAttributes = \array_merge($useAttributes, ['status', 'version', 'type', 'serverVersion', 'xmlns']);
1993
			}
1994
			$responseData['subsonic-response']['xmlns'] = 'http://subsonic.org/restapi';
1995
			$response = new XmlResponse($responseData, $useAttributes);
1996
		}
1997
1998
		return $response;
1999
	}
2000
2001
	public function subsonicErrorResponse(int $errorCode, string $errorMessage) : Response {
2002
		return $this->subsonicResponse([
2003
				'error' => [
2004
					'code' => $errorCode,
2005
					'message' => $errorMessage
2006
				]
2007
			], true, 'failed');
2008
	}
2009
}
2010