SubsonicController   F
last analyzed

Complexity

Total Complexity 262

Size/Duplication

Total Lines 1932
Duplicated Lines 0 %

Importance

Changes 29
Bugs 0 Features 1
Metric Value
eloc 920
c 29
b 0
f 1
dl 0
loc 1932
rs 1.68
wmc 262

108 Methods

Rating   Name   Duplication   Size   Complexity  
A getSimilarSongs2() 0 2 1
A getArtistInfo2() 0 2 1
A getMusicDirectory() 0 11 5
A getArtists() 0 2 1
A tokenInfo() 0 5 1
A getAlbumList2() 0 12 1
A deleteInternetRadioStation() 0 3 1
A getSongsByGenre() 0 5 1
A getUser() 0 26 2
A getCoverArt() 0 23 6
A updateInternetRadioStation() 0 3 1
A getAlbumInfo2() 0 2 1
A getRandomSongs() 0 21 6
A setResponseFormat() 0 3 1
A ping() 0 2 1
A getArtist() 0 10 1
A createPlaylist() 0 17 3
A getGenres() 0 9 1
A getIndexes() 0 5 2
A createInternetRadioStation() 0 3 1
A getMusicFolders() 0 6 1
A getTopSongs() 0 4 1
A getAlbumInfo() 0 2 1
A getSimilarSongs() 0 2 1
A getAlbumList() 0 5 1
A getSong() 0 4 1
A updatePlaylist() 0 22 5
A search3() 0 4 1
A search2() 0 4 1
A getInternetRadioStations() 0 10 2
A download() 0 21 5
A getUsers() 0 2 1
A getLyrics() 0 23 3
A getAlbum() 0 9 1
A __construct() 0 56 1
A getAvatar() 0 12 3
A getLyricsBySongId() 0 26 2
B handleRequest() 0 34 8
A getArtistInfo() 0 2 1
A stream() 0 3 1
A getPlaylist() 0 12 1
A deletePlaylist() 0 3 1
A getPlaylists() 0 10 2
A getLicense() 0 4 1
A setAuthenticatedUser() 0 4 1
A refreshPodcasts() 0 3 1
A getIndexesForArtists() 0 19 3
A artistImageUrl() 0 6 1
A findTracksByGenre() 0 7 2
A getFilesystemNode() 0 9 2
A albumCommonApiFields() 0 22 5
B getArtistIdFromEntityId() 0 23 7
A createPodcastChannel() 0 14 5
A folderToApi() 0 5 1
A artistToApi() 0 19 6
A getMusicDirectoryForPodcastChannel() 0 13 2
A getIndexingChar() 0 13 3
A savePlayQueue() 0 17 5
A user() 0 5 2
B getPlayQueueByIndex() 0 34 8
A getNewestPodcasts() 0 6 1
A getIndexesForFolders() 0 28 3
A subsonicResponse() 0 23 4
A parseEntityId() 0 7 2
A apiEntryIdsToApiEntries() 0 40 1
A trackToApi() 0 2 1
A getPodcasts() 0 15 3
A getNowPlaying() 0 15 2
A getScanStatus() 0 4 1
A getStarred2() 0 3 1
A ripIdPrefix() 0 2 1
A getPlayQueue() 0 11 2
A getOpenSubsonicExtensions() 0 7 1
A parseBookmarkIdParam() 0 12 3
A getMusicDirectoryForArtist() 0 11 1
A unstar() 0 11 1
A savePlayQueueByIndex() 0 24 5
A tracksToApi() 0 6 1
B parseStarringParameters() 0 40 8
A star() 0 11 1
A ensureParamHasValue() 0 3 3
A podcastEpisodesToApi() 0 2 1
B albumsForGetAlbumList() 0 49 11
A deletePodcastChannel() 0 11 3
A doGetAlbumInfo() 0 26 5
A createBookmark() 0 4 1
A doGetArtistInfo() 0 30 4
A getBookmarks() 0 26 5
A getAlbumIdFromEntityId() 0 21 6
A albumToNewApi() 0 9 1
A doGetSimilarSongs() 0 29 5
A deleteBookmark() 0 7 1
A findGenreByName() 0 6 4
B setRating() 0 26 6
A doGetStarred() 0 6 1
A doSearch() 0 12 1
A scrobble() 0 26 6
A getStarred() 0 3 1
A albumToOldApi() 0 9 1
A nameWithoutArticle() 0 2 1
A findAlbumsByGenre() 0 7 2
A getPodcastEpisode() 0 10 2
A getVideos() 0 5 1
A getMusicDirectoryForAlbum() 0 13 1
A subsonicErrorResponse() 0 7 1
A searchResponse() 0 7 2
A getMusicDirectoryForFolder() 0 31 3
A getSubFoldersAndTracks() 0 9 2

How to fix   Complexity   

Complex Class

Complex classes like SubsonicController often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use SubsonicController, and based on these observations, apply Extract Interface, too.

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