Passed
Push — master ( 012442...4c4644 )
by Pauli
03:10 queued 15s
created

SubsonicController   F

Complexity

Total Complexity 237

Size/Duplication

Total Lines 1732
Duplicated Lines 0 %

Importance

Changes 11
Bugs 0 Features 0
Metric Value
eloc 810
c 11
b 0
f 0
dl 0
loc 1732
rs 1.79
wmc 237

103 Methods

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