SubsonicController::__construct()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 54
Code Lines 28

Duplication

Lines 0
Ratio 0 %

Importance

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

How to fix   Long Method    Many Parameters   

Long Method

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

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

Commonly applied refactorings include:

Many Parameters

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

There are several approaches to avoid long parameter lists:

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