Passed
Push — master ( 2be3f6...c25ba4 )
by Pauli
03:39
created

SubsonicController::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 52
Code Lines 27

Duplication

Lines 0
Ratio 0 %

Importance

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

How to fix   Long Method    Many Parameters   

Long Method

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

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

Commonly applied refactorings include:

Many Parameters

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

There are several approaches to avoid long parameter lists:

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