SubsonicController::search3()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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