SubsonicController::albumToOldApi()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 9
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

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

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

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