SubsonicController::__construct()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 48
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 25
nc 1
nop 22
dl 0
loc 48
rs 9.52
c 1
b 0
f 0

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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