Passed
Push — master ( 23afda...b1ae99 )
by Pauli
03:08
created

SubsonicController::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 48
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Importance

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

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

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

1767
		$token = $this->imageService->getToken('artist', $id, /** @scrutinizer ignore-type */ $this->keyId);
Loading history...
1768
		return $this->urlGenerator->linkToRouteAbsolute('music.ampacheImage.image',
1769
			['object_type' => 'artist', 'object_id' => $id, 'token' => $token, 'size' => CoverHelper::DO_NOT_CROP_OR_SCALE]);
1770
	}
1771
1772
	/**
1773
	 * Given a prefixed ID like 'artist-123' or 'track-45', return the string part and the numeric part.
1774
	 * @throws SubsonicException if the \a $id doesn't follow the expected pattern
1775
	 */
1776
	private static function parseEntityId(string $id) : array {
1777
		$parts = \explode('-', $id);
1778
		if (\count($parts) !== 2) {
1779
			throw new SubsonicException("Unexpected ID format: $id", 0);
1780
		}
1781
		$parts[1] = (int)$parts[1];
1782
		return $parts;
1783
	}
1784
1785
	/**
1786
	 * Given a prefixed ID like 'artist-123' or 'track-45', return just the numeric part.
1787
	 */
1788
	private static function ripIdPrefix(string $id) : int {
1789
		return self::parseEntityId($id)[1];
1790
	}
1791
1792
	private function subsonicResponse($content, $useAttributes=true, $status = 'ok') {
1793
		$content['status'] = $status;
1794
		$content['version'] = self::API_VERSION;
1795
		$content['type'] = AppInfo::getFullName();
1796
		$content['serverVersion'] = AppInfo::getVersion();
1797
		$content['openSubsonic'] = true;
1798
		$responseData = ['subsonic-response' => Util::arrayRejectRecursive($content, 'is_null')];
1799
1800
		if ($this->format == 'json') {
1801
			$response = new JSONResponse($responseData);
1802
		} elseif ($this->format == 'jsonp') {
1803
			$responseData = \json_encode($responseData);
1804
			$response = new DataDisplayResponse("{$this->callback}($responseData);");
1805
			$response->addHeader('Content-Type', 'text/javascript; charset=UTF-8');
1806
		} else {
1807
			if (\is_array($useAttributes)) {
1808
				$useAttributes = \array_merge($useAttributes, ['status', 'version', 'type', 'serverVersion', 'xmlns']);
1809
			}
1810
			$responseData['subsonic-response']['xmlns'] = 'http://subsonic.org/restapi';
1811
			$response = new XmlResponse($responseData, $useAttributes);
1812
		}
1813
1814
		return $response;
1815
	}
1816
1817
	public function subsonicErrorResponse($errorCode, $errorMessage) {
1818
		return $this->subsonicResponse([
1819
				'error' => [
1820
					'code' => $errorCode,
1821
					'message' => $errorMessage
1822
				]
1823
			], true, 'failed');
1824
	}
1825
}
1826