Issues (183)

lib/Controller/SubsonicController.php (1 issue)

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

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

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