Passed
Push — master ( 91b14a...fc89b7 )
by Pauli
02:49
created

AmpacheController::artists()   A

Complexity

Conditions 5
Paths 12

Size

Total Lines 23
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 5
eloc 14
c 2
b 0
f 0
nc 12
nop 8
dl 0
loc 23
rs 9.4888

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 Morris Jobke <[email protected]>
10
 * @author Pauli Järvinen <[email protected]>
11
 * @copyright Morris Jobke 2013, 2014
12
 * @copyright Pauli Järvinen 2017 - 2024
13
 */
14
15
namespace OCA\Music\Controller;
16
17
use OCP\AppFramework\Controller;
18
use OCP\AppFramework\Http;
19
use OCP\AppFramework\Http\JSONResponse;
20
use OCP\AppFramework\Http\RedirectResponse;
21
use OCP\AppFramework\Http\Response;
22
use OCP\IConfig;
23
use OCP\IL10N;
24
use OCP\IRequest;
25
use OCP\IURLGenerator;
26
use OCP\IUserManager;
27
28
use OCA\Music\AppFramework\BusinessLayer\BusinessLayer;
29
use OCA\Music\AppFramework\BusinessLayer\BusinessLayerException;
30
use OCA\Music\AppFramework\Core\Logger;
31
use OCA\Music\AppFramework\Utility\MethodAnnotationReader;
32
use OCA\Music\AppFramework\Utility\RequestParameterExtractor;
33
use OCA\Music\AppFramework\Utility\RequestParameterExtractorException;
34
35
use OCA\Music\BusinessLayer\AlbumBusinessLayer;
36
use OCA\Music\BusinessLayer\ArtistBusinessLayer;
37
use OCA\Music\BusinessLayer\BookmarkBusinessLayer;
38
use OCA\Music\BusinessLayer\GenreBusinessLayer;
39
use OCA\Music\BusinessLayer\Library;
40
use OCA\Music\BusinessLayer\PlaylistBusinessLayer;
41
use OCA\Music\BusinessLayer\PodcastChannelBusinessLayer;
42
use OCA\Music\BusinessLayer\PodcastEpisodeBusinessLayer;
43
use OCA\Music\BusinessLayer\RadioStationBusinessLayer;
44
use OCA\Music\BusinessLayer\TrackBusinessLayer;
45
46
use OCA\Music\Db\Album;
47
use OCA\Music\Db\AmpacheSession;
48
use OCA\Music\Db\Artist;
49
use OCA\Music\Db\BaseMapper;
50
use OCA\Music\Db\Bookmark;
51
use OCA\Music\Db\Entity;
52
use OCA\Music\Db\Genre;
53
use OCA\Music\Db\RadioStation;
54
use OCA\Music\Db\MatchMode;
55
use OCA\Music\Db\Playlist;
56
use OCA\Music\Db\PodcastChannel;
57
use OCA\Music\Db\PodcastEpisode;
58
use OCA\Music\Db\SortBy;
59
use OCA\Music\Db\Track;
60
61
use OCA\Music\Http\ErrorResponse;
62
use OCA\Music\Http\FileResponse;
63
use OCA\Music\Http\FileStreamResponse;
64
use OCA\Music\Http\XmlResponse;
65
66
use OCA\Music\Middleware\AmpacheException;
67
68
use OCA\Music\Utility\AmpacheImageService;
69
use OCA\Music\Utility\AmpachePreferences;
70
use OCA\Music\Utility\AppInfo;
71
use OCA\Music\Utility\CoverHelper;
72
use OCA\Music\Utility\LastfmService;
73
use OCA\Music\Utility\LibrarySettings;
74
use OCA\Music\Utility\PodcastService;
75
use OCA\Music\Utility\Random;
76
use OCA\Music\Utility\Util;
77
78
class AmpacheController extends Controller {
79
	private $config;
80
	private $l10n;
81
	private $urlGenerator;
82
	private $userManager;
83
	private $albumBusinessLayer;
84
	private $artistBusinessLayer;
85
	private $bookmarkBusinessLayer;
86
	private $genreBusinessLayer;
87
	private $playlistBusinessLayer;
88
	private $podcastChannelBusinessLayer;
89
	private $podcastEpisodeBusinessLayer;
90
	private $radioStationBusinessLayer;
91
	private $trackBusinessLayer;
92
	private $library;
93
	private $podcastService;
94
	private $imageService;
95
	private $coverHelper;
96
	private $lastfmService;
97
	private $librarySettings;
98
	private $random;
99
	private $logger;
100
101
	private $jsonMode;
102
	private $session;
103
	private $namePrefixes;
104
105
	const ALL_TRACKS_PLAYLIST_ID = -1;
106
	const API4_VERSION = '440000';
107
	const API5_VERSION = '560000';
108
	const API6_VERSION = '630000';
109
	const API_MIN_COMPATIBLE_VERSION = '350001';
110
111
	public function __construct(string $appname,
112
								IRequest $request,
113
								IConfig $config,
114
								IL10N $l10n,
115
								IURLGenerator $urlGenerator,
116
								IUserManager $userManager,
117
								AlbumBusinessLayer $albumBusinessLayer,
118
								ArtistBusinessLayer $artistBusinessLayer,
119
								BookmarkBusinessLayer $bookmarkBusinessLayer,
120
								GenreBusinessLayer $genreBusinessLayer,
121
								PlaylistBusinessLayer $playlistBusinessLayer,
122
								PodcastChannelBusinessLayer $podcastChannelBusinessLayer,
123
								PodcastEpisodeBusinessLayer $podcastEpisodeBusinessLayer,
124
								RadioStationBusinessLayer $radioStationBusinessLayer,
125
								TrackBusinessLayer $trackBusinessLayer,
126
								Library $library,
127
								PodcastService $podcastService,
128
								AmpacheImageService $imageService,
129
								CoverHelper $coverHelper,
130
								LastfmService $lastfmService,
131
								LibrarySettings $librarySettings,
132
								Random $random,
133
								Logger $logger) {
134
		parent::__construct($appname, $request);
135
136
		$this->config = $config;
137
		$this->l10n = $l10n;
138
		$this->urlGenerator = $urlGenerator;
139
		$this->userManager = $userManager;
140
		$this->albumBusinessLayer = $albumBusinessLayer;
141
		$this->artistBusinessLayer = $artistBusinessLayer;
142
		$this->bookmarkBusinessLayer = $bookmarkBusinessLayer;
143
		$this->genreBusinessLayer = $genreBusinessLayer;
144
		$this->playlistBusinessLayer = $playlistBusinessLayer;
145
		$this->podcastChannelBusinessLayer = $podcastChannelBusinessLayer;
146
		$this->podcastEpisodeBusinessLayer = $podcastEpisodeBusinessLayer;
147
		$this->radioStationBusinessLayer = $radioStationBusinessLayer;
148
		$this->trackBusinessLayer = $trackBusinessLayer;
149
		$this->library = $library;
150
		$this->podcastService = $podcastService;
151
		$this->imageService = $imageService;
152
		$this->coverHelper = $coverHelper;
153
		$this->lastfmService = $lastfmService;
154
		$this->librarySettings = $librarySettings;
155
		$this->random = $random;
156
		$this->logger = $logger;
157
	}
158
159
	public function setJsonMode(bool $useJsonMode) : void {
160
		$this->jsonMode = $useJsonMode;
161
	}
162
163
	public function setSession(AmpacheSession $session) : void {
164
		$this->session = $session;
165
		$this->namePrefixes = $this->librarySettings->getIgnoredArticles($session->getUserId());
166
	}
167
168
	public function ampacheResponse(array $content) : Response {
169
		if ($this->jsonMode) {
170
			return new JSONResponse($this->prepareResultForJsonApi($content));
171
		} else {
172
			return new XmlResponse($this->prepareResultForXmlApi($content), ['id', 'index', 'count', 'code', 'errorCode'], true, true, 'text');
173
		}
174
	}
175
176
	public function ampacheErrorResponse(int $code, string $message) : Response {
177
		$this->logger->log($message, 'debug');
178
179
		if ($this->apiMajorVersion() > 4) {
180
			$code = $this->mapApiV4ErrorToV5($code);
181
			$content = [
182
				'error' => [
183
					'errorCode' => (string)$code,
184
					'errorAction' => $this->request->getParam('action'),
185
					'errorType' => 'system',
186
					'errorMessage' => $message
187
				]
188
			];
189
		} else {
190
			$content = [
191
				'error' => [
192
					'code' => (string)$code,
193
					'text' => $message
194
				]
195
			];
196
		}
197
		return $this->ampacheResponse($content);
198
	}
199
200
	/**
201
	 * @NoAdminRequired
202
	 * @PublicPage
203
	 * @NoCSRFRequired
204
	 * @NoSameSiteCookieRequired
205
	 */
206
	public function xmlApi(string $action) : Response {
207
		// differentiation between xmlApi and jsonApi is made already by the middleware
208
		return $this->dispatch($action);
209
	}
210
211
	/**
212
	 * @NoAdminRequired
213
	 * @PublicPage
214
	 * @NoCSRFRequired
215
	 * @NoSameSiteCookieRequired
216
	 */
217
	public function jsonApi(string $action) : Response {
218
		// differentiation between xmlApi and jsonApi is made already by the middleware
219
		return $this->dispatch($action);
220
	}
221
222
	protected function dispatch(string $action) : Response {
223
		$this->logger->log("Ampache action '$action' requested", 'debug');
224
225
		// Allow calling any functions annotated to be part of the API
226
		if (\method_exists($this, $action)) {
227
			$annotationReader = new MethodAnnotationReader($this, $action);
228
			if ($annotationReader->hasAnnotation('AmpacheAPI')) {
229
				// custom "filter" which modifies the value of the request argument `limit`
230
				$limitFilter = function(?string $value) : int {
231
					// Any non-integer values and integer value 0 are interpreted as "no limit".
232
					// On the other hand, the API spec mandates limiting responses to 5000 entries
233
					// even if no limit or larger limit has been passed.
234
					$value = (int)$value;
235
					if ($value <= 0) {
236
						$value = 5000;
237
					}
238
					return \min($value, 5000);
239
				};
240
241
				$parameterExtractor = new RequestParameterExtractor($this->request, ['limit' => $limitFilter]);
242
				try {
243
					$parameterValues = $parameterExtractor->getParametersForMethod($this, $action);
244
				} catch (RequestParameterExtractorException $ex) {
245
					throw new AmpacheException($ex->getMessage(), 400);
246
				}
247
				$response = \call_user_func_array([$this, $action], $parameterValues);
248
				// The API methods may return either a Response object or an array, which should be converted to Response
249
				if (!($response instanceof Response)) {
250
					$response = $this->ampacheResponse($response);
251
				}
252
				return $response;
253
			}
254
		}
255
256
		// No method was found for this action
257
		$this->logger->log("Unsupported Ampache action '$action' requested", 'warn');
258
		throw new AmpacheException('Action not supported', 405);
259
	}
260
261
	/***********************
262
	 * Ampache API methods *
263
	 ***********************/
264
265
	/**
266
	 * Get the handshake result. The actual user authentication and session creation logic has happened prior to calling
267
	 * this in the class AmpacheMiddleware.
268
	 * 
269
	 * @AmpacheAPI
270
	 */
271
	 protected function handshake() : array {
272
		$user = $this->session->getUserId();
273
		$updateTime = \max($this->library->latestUpdateTime($user), $this->playlistBusinessLayer->latestUpdateTime($user));
274
		$addTime = \max($this->library->latestInsertTime($user), $this->playlistBusinessLayer->latestInsertTime($user));
275
		$genresKey = $this->genreKey() . 's';
276
		$playlistCount = $this->playlistBusinessLayer->count($user);
277
278
		return [
279
			'session_expire' => \date('c', $this->session->getExpiry()),
280
			'auth' => $this->session->getToken(),
281
			'api' => $this->apiVersionString(),
282
			'update' => $updateTime->format('c'),
283
			'add' => $addTime->format('c'),
284
			'clean' => \date('c', \time()), // TODO: actual time of the latest item removal
285
			'songs' => $this->trackBusinessLayer->count($user),
286
			'artists' => $this->artistBusinessLayer->count($user),
287
			'albums' => $this->albumBusinessLayer->count($user),
288
			'playlists' => $playlistCount,
289
			'searches' => 1, // "All tracks"
290
			'playlists_searches' => $playlistCount + 1,
291
			'podcasts' => $this->podcastChannelBusinessLayer->count($user),
292
			'podcast_episodes' => $this->podcastEpisodeBusinessLayer->count($user),
293
			'live_streams' => $this->radioStationBusinessLayer->count($user),
294
			$genresKey => $this->genreBusinessLayer->count($user),
295
			'videos' => 0,
296
			'catalogs' => 0,
297
			'shares' => 0,
298
			'licenses' => 0,
299
			'labels' => 0
300
		];
301
	}
302
303
	/**
304
	 * Get the result for the 'goodbye' command. The actual logout is handled by AmpacheMiddleware.
305
	 * 
306
	 * @AmpacheAPI
307
	 */
308
	protected function goodbye() : array {
309
		return ['success' => "goodbye: {$this->session->getToken()}"];
310
	}
311
312
	/**
313
	 * @AmpacheAPI
314
	 */
315
	protected function ping() : array {
316
		$response = [
317
			'server' => AppInfo::getFullName() . ' ' . AppInfo::getVersion(),
318
			'version' => self::API6_VERSION,
319
			'compatible' => self::API_MIN_COMPATIBLE_VERSION
320
		];
321
322
		if ($this->session) {
323
			// in case ping is called within a valid session, the response will contain also the "handshake fields"
324
			$response += $this->handshake();
325
		}
326
327
		return $response;
328
	}
329
330
	/**
331
	 * @AmpacheAPI
332
	 */
333
	protected function get_indexes(string $type, ?string $filter, ?string $add, ?string $update, ?bool $include, int $limit, int $offset=0) : array {
334
		if ($type === 'album_artist') {
335
			$type = 'artist';
336
			list($addMin, $addMax, $updateMin, $updateMax) = self::parseTimeParameters($add, $update);
337
			$entities = $this->artistBusinessLayer->findAllHavingAlbums(
338
				$this->session->getUserId(), SortBy::Name, $limit, $offset, $filter, MatchMode::Substring, $addMin, $addMax, $updateMin, $updateMax);
339
		} else {
340
			$businessLayer = $this->getBusinessLayer($type);
341
			$entities = $this->findEntities($businessLayer, $filter, false, $limit, $offset, $add, $update);
342
		}
343
344
		// We support the 'include' argument only for podcasts. On the original Ampache server, also other types have support but
345
		// only 'podcast' and 'playlist' are documented to be supported and the implementation is really messy for the 'playlist'
346
		// type, with inconsistencies between XML and JSON formats and XML-structures unlike any other actions.
347
		if ($type == 'podcast' && $include) {
348
			$this->injectEpisodesToChannels($entities);
349
		}
350
351
		return $this->renderEntitiesIndex($entities, $type);
352
	}
353
354
	/**
355
	 * @AmpacheAPI
356
	 */
357
	protected function index(
358
			string $type, ?string $filter, ?string $add, ?string $update,
359
			?bool $include, int $limit, int $offset=0, bool $exact=false) : array {
360
		$userId = $this->session->getUserId();
361
		
362
		if ($type === 'album_artist' || $type === 'song_artist') {
363
			list($addMin, $addMax, $updateMin, $updateMax) = self::parseTimeParameters($add, $update);
364
			$matchMode = $exact ? MatchMode::Exact : MatchMode::Substring;
365
			if ($type === 'album_artist') {
366
				$entities = $this->artistBusinessLayer->findAllHavingAlbums(
367
					$userId, SortBy::Name, $limit, $offset, $filter, $matchMode, $addMin, $addMax, $updateMin, $updateMax);
368
			} else {
369
				$entities = $this->artistBusinessLayer->findAllHavingTracks(
370
					$userId, SortBy::Name, $limit, $offset, $filter, $matchMode, $addMin, $addMax, $updateMin, $updateMax);
371
			}
372
		} else {
373
			$businessLayer = $this->getBusinessLayer($type);
374
			$entities = $this->findEntities($businessLayer, $filter, $exact, $limit, $offset, $add, $update);
375
		}
376
377
		if ($include) {
378
			$childType = self::getChildEntityType($type);
379
			if ($childType !== null) {
380
				if ($type == 'playlist') {
381
					$idsWithChildren = [];
382
					foreach ($entities as $playlist) {
383
						\assert($playlist instanceof Playlist);
384
						$idsWithChildren[$playlist->getId()] = $playlist->getTrackIdsAsArray();
385
					}
386
				} else {
387
					$idsWithChildren = $this->getBusinessLayer($childType)->findAllIdsByParentIds($userId, Util::extractIds($entities));
388
				}
389
				return $this->renderIdsWithChildren($idsWithChildren, $type, $childType);
390
			}
391
		}
392
393
		return $this->renderEntityIdIndex($entities, $type);
394
	}
395
396
	/**
397
	 * @AmpacheAPI
398
	 */
399
	protected function list(string $type, ?string $filter, ?string $add, ?string $update, int $limit, int $offset=0) : array {
400
		$isAlbumArtist = ($type == 'album_artist');
401
		if ($isAlbumArtist) {
402
			$type = 'artist';
403
		}
404
405
		list($addMin, $addMax, $updateMin, $updateMax) = self::parseTimeParameters($add, $update);
406
407
		$businessLayer = $this->getBusinessLayer($type);
408
		$entities = $businessLayer->findAllIdsAndNames(
409
			$this->session->getUserId(), $this->l10n, null, $limit, $offset, $addMin, $addMax, $updateMin, $updateMax, $isAlbumArtist, $filter);
410
411
		return [
412
			'list' => \array_map(function($idAndName) {
413
				return $idAndName + $this->prefixAndBaseName($idAndName['name']);
414
			}, $entities)
415
		];
416
	}
417
418
	/**
419
	 * @AmpacheAPI
420
	 */
421
	protected function browse(string $type, ?string $filter, ?string $add, ?string $update, int $limit, int $offset=0) : array {
422
		// note: the argument 'catalog' is disregarded in our implementation
423
		if ($type == 'root') {
424
			$catalogId = null;
425
			$childType = 'catalog';
426
			$children = [
427
				['id' => 'music', 'name' => 'music'],
428
				['id' => 'podcasts', 'name' => 'podcasts']
429
			];
430
		} else {
431
			if ($type == 'catalog') {
432
				$catalogId = null;
433
				$parentId = null;
434
435
				switch ($filter) {
436
					case 'music':
437
						$childType = 'artist';
438
						break;
439
					case 'podcasts':
440
						$childType = 'podcast';
441
						break;
442
					default:
443
						throw new AmpacheException("Filter '$filter' is not a valid catalog", 400);
444
				}
445
			} else {
446
				$catalogId = Util::startsWith($type, 'podcast') ? 'podcasts' : 'music';
447
				$parentId = empty($filter) ? null : (int)$filter;
448
449
				switch ($type) {
450
					case 'podcast':
451
						$childType = 'podcast_episode';
452
						break;
453
					case 'artist':
454
						$childType = 'album';
455
						break;
456
					case 'album':
457
						$childType = 'song';
458
						break;
459
					default:
460
						throw new AmpacheException("Type '$type' is not supported", 400);
461
				}
462
			}
463
464
			$businessLayer = $this->getBusinessLayer($childType);
465
			list($addMin, $addMax, $updateMin, $updateMax) = self::parseTimeParameters($add, $update);
466
			$children = $businessLayer->findAllIdsAndNames(
467
				$this->session->getUserId(), $this->l10n, $parentId, $limit, $offset, $addMin, $addMax, $updateMin, $updateMax, true);
468
		}
469
470
		return [
471
			'catalog_id' => $catalogId,
472
			'parent_id' => $filter,
473
			'parent_type' => $type,
474
			'child_type' => $childType,
475
			'browse' => \array_map(function($idAndName) {
476
				return $idAndName + $this->prefixAndBaseName($idAndName['name']);
477
			}, $children)
478
		];
479
	}
480
481
	/**
482
	 * @AmpacheAPI
483
	 */
484
	protected function stats(string $type, ?string $filter, int $limit, int $offset=0) : array {
485
		$userId = $this->session->getUserId();
486
487
		// Support for API v3.x: Originally, there was no 'filter' argument and the 'type'
488
		// argument had that role. The action only supported albums in this old format.
489
		// The 'filter' argument was added and role of 'type' changed in API v4.0.
490
		if (empty($filter)) {
491
			$filter = $type;
492
			$type = 'album';
493
		}
494
495
		// Note: In addition to types specified in APIv6, we support also types 'genre' and 'live_stream'
496
		// as that's possible without extra effort. All types don't support all possible filters.
497
		$businessLayer = $this->getBusinessLayer($type);
498
499
		$getEntitiesIfSupported = function(
500
				BusinessLayer $businessLayer, string $method, string $userId,
501
				int $limit, int $offset) use ($type, $filter) {
502
			if (\method_exists($businessLayer, $method)) {
503
				return $businessLayer->$method($userId, $limit, $offset);
504
			} else {
505
				throw new AmpacheException("Filter $filter not supported for type $type", 400);
506
			}
507
		};
508
509
		switch ($filter) {
510
			case 'newest':
511
				$entities = $businessLayer->findAll($userId, SortBy::Newest, $limit, $offset);
512
				break;
513
			case 'flagged':
514
				$entities = $businessLayer->findAllStarred($userId, $limit, $offset);
515
				break;
516
			case 'random':
517
				$entities = $businessLayer->findAll($userId, SortBy::Name);
518
				$indices = $this->random->getIndices(\count($entities), $offset, $limit, $userId, 'ampache_stats_'.$type);
519
				$entities = Util::arrayMultiGet($entities, $indices);
520
				break;
521
			case 'frequent':
522
				$entities = $getEntitiesIfSupported($businessLayer, 'findFrequentPlay', $userId, $limit, $offset);
523
				break;
524
			case 'recent':
525
				$entities = $getEntitiesIfSupported($businessLayer, 'findRecentPlay', $userId, $limit, $offset);
526
				break;
527
			case 'forgotten':
528
				$entities = $getEntitiesIfSupported($businessLayer, 'findNotRecentPlay', $userId, $limit, $offset);
529
				break;
530
			case 'highest':
531
				$entities = $businessLayer->findAllRated($userId, $limit, $offset);
532
				break;
533
			default:
534
				throw new AmpacheException("Unsupported filter $filter", 400);
535
		}
536
537
		return $this->renderEntities($entities, $type);
538
	}
539
540
	/**
541
	 * @AmpacheAPI
542
	 */
543
	protected function artists(
544
			?string $filter, ?string $add, ?string $update, int $limit, int $offset=0,
545
			bool $exact=false, bool $album_artist=false, ?string $include=null) : array {
546
		$userId = $this->session->getUserId();
547
548
		if ($album_artist) {
549
			$matchMode =  $exact ? MatchMode::Exact : MatchMode::Substring;
550
			list($addMin, $addMax, $updateMin, $updateMax) = self::parseTimeParameters($add, $update);
551
			$artists = $this->artistBusinessLayer->findAllHavingAlbums(
552
				$userId, SortBy::Name, $limit, $offset, $filter, $matchMode, $addMin, $addMax, $updateMin, $updateMax);
553
		} else {
554
			$artists = $this->findEntities($this->artistBusinessLayer, $filter, $exact, $limit, $offset, $add, $update);
555
		}
556
557
		$include = Util::explode(',', $include);
558
		if (\in_array('songs', $include)) {
559
			$this->library->injectTracksToArtists($artists, $userId);
560
		}
561
		if (\in_array('albums', $include)) {
562
			$this->library->injectAlbumsToArtists($artists, $userId);
563
		}
564
565
		return $this->renderArtists($artists);
566
	}
567
568
	/**
569
	 * @AmpacheAPI
570
	 */
571
	protected function artist(int $filter, ?string $include) : array {
572
		$userId = $this->session->getUserId();
573
		$artists = [$this->artistBusinessLayer->find($filter, $userId)];
574
575
		$include = Util::explode(',', $include);
576
		if (\in_array('songs', $include)) {
577
			$this->library->injectTracksToArtists($artists, $userId);
578
		}
579
		if (\in_array('albums', $include)) {
580
			$this->library->injectAlbumsToArtists($artists, $userId);
581
		}
582
583
		return $this->renderArtists($artists);
584
	}
585
586
	/**
587
	 * @AmpacheAPI
588
	 */
589
	protected function artist_albums(int $filter, int $limit, int $offset=0) : array {
590
		$userId = $this->session->getUserId();
591
		$albums = $this->albumBusinessLayer->findAllByArtist($filter, $userId, $limit, $offset);
592
		return $this->renderAlbums($albums);
593
	}
594
595
	/**
596
	 * @AmpacheAPI
597
	 */
598
	protected function artist_songs(int $filter, int $limit, int $offset=0, bool $top50=false) : array {
599
		$userId = $this->session->getUserId();
600
		if ($top50) {
601
			$tracks = $this->lastfmService->getTopTracks($filter, $userId, 50);
602
			$tracks = \array_slice($tracks, $offset, $limit);
603
		} else {
604
			$tracks = $this->trackBusinessLayer->findAllByArtist($filter, $userId, $limit, $offset);
605
		}
606
		return $this->renderSongs($tracks);
607
	}
608
609
	/**
610
	 * @AmpacheAPI
611
	 */
612
	protected function album_songs(int $filter, int $limit, int $offset=0) : array {
613
		$userId = $this->session->getUserId();
614
		$tracks = $this->trackBusinessLayer->findAllByAlbum($filter, $userId, null, $limit, $offset);
615
		return $this->renderSongs($tracks);
616
	}
617
618
	/**
619
	 * @AmpacheAPI
620
	 */
621
	protected function song(int $filter) : array {
622
		$userId = $this->session->getUserId();
623
		$track = $this->trackBusinessLayer->find($filter, $userId);
624
		$trackInArray = [$track];
625
		return $this->renderSongs($trackInArray);
626
	}
627
628
	/**
629
	 * @AmpacheAPI
630
	 */
631
	protected function songs(
632
			?string $filter, ?string $add, ?string $update,
633
			int $limit, int $offset=0, bool $exact=false) : array {
634
635
		$tracks = $this->findEntities($this->trackBusinessLayer, $filter, $exact, $limit, $offset, $add, $update);
636
		return $this->renderSongs($tracks);
637
	}
638
639
	/**
640
	 * @AmpacheAPI
641
	 */
642
	protected function search_songs(string $filter, int $limit, int $offset=0) : array {
643
		$userId = $this->session->getUserId();
644
		$tracks = $this->trackBusinessLayer->findAllByNameRecursive($filter, $userId, $limit, $offset);
645
		return $this->renderSongs($tracks);
646
	}
647
648
	/**
649
	 * @AmpacheAPI
650
	 */
651
	protected function albums(
652
			?string $filter, ?string $add, ?string $update, int $limit, int $offset=0,
653
			bool $exact=false, ?string $include=null) : array {
654
655
		$albums = $this->findEntities($this->albumBusinessLayer, $filter, $exact, $limit, $offset, $add, $update);
656
657
		if ($include == 'songs') {
658
			$this->library->injectTracksToAlbums($albums, $this->session->getUserId());
659
		}
660
661
		return $this->renderAlbums($albums);
662
	}
663
664
	/**
665
	 * @AmpacheAPI
666
	 */
667
	protected function album(int $filter, ?string $include) : array {
668
		$userId = $this->session->getUserId();
669
		$albums = [$this->albumBusinessLayer->find($filter, $userId)];
670
671
		if ($include == 'songs') {
672
			$this->library->injectTracksToAlbums($albums, $this->session->getUserId());
673
		}
674
675
		return $this->renderAlbums($albums);
676
	}
677
678
	/**
679
	 * @AmpacheAPI
680
	 */
681
	protected function get_similar(string $type, int $filter, int $limit, int $offset=0) : array {
682
		$userId = $this->session->getUserId();
683
		if ($type == 'artist') {
684
			$entities = $this->lastfmService->getSimilarArtists($filter, $userId);
685
		} elseif ($type == 'song') {
686
			$entities = $this->lastfmService->getSimilarTracks($filter, $userId);
687
		} else {
688
			throw new AmpacheException("Type '$type' is not supported", 400);
689
		}
690
		$entities = \array_slice($entities, $offset, $limit);
691
		return $this->renderEntities($entities, $type);
692
	}
693
694
	/**
695
	 * @AmpacheAPI
696
	 */
697
	protected function playlists(
698
			?string $filter, ?string $add, ?string $update,
699
			int $limit, int $offset=0, bool $exact=false, int $hide_search=0) : array {
700
701
		$userId = $this->session->getUserId();
702
		$playlists = $this->findEntities($this->playlistBusinessLayer, $filter, $exact, $limit, $offset, $add, $update);
703
704
		// append "All tracks" if "searches" are not forbidden, and not filtering by any criteria, and it is not off-limits
705
		$allTracksIndex = $this->playlistBusinessLayer->count($userId);
706
		if (!$hide_search && empty($filter) && empty($add) && empty($update)
707
				&& self::indexIsWithinOffsetAndLimit($allTracksIndex, $offset, $limit)) {
708
			$playlists[] = $this->getAllTracksPlaylist();
709
		}
710
711
		return $this->renderPlaylists($playlists);
712
	}
713
714
	/**
715
	 * @AmpacheAPI
716
	 */
717
	protected function user_playlists(
718
			?string $filter, ?string $add, ?string $update,	int $limit, int $offset=0, bool $exact=false) : array {
719
		// alias for playlists without smart lists
720
		return $this->playlists($filter, $add, $update, $limit, $offset, $exact, 1);
721
	}
722
723
	/**
724
	 * @AmpacheAPI
725
	 */
726
	protected function user_smartlists() {
727
		// the only "smart list" currently supported is "All tracks", hence supporting any kind of filtering criteria
728
		// isn't worthwhile
729
		return $this->renderPlaylists([$this->getAllTracksPlaylist()]);
730
	}
731
732
	/**
733
	 * @AmpacheAPI
734
	 */
735
	protected function playlist(int $filter) : array {
736
		$userId = $this->session->getUserId();
737
		if ($filter == self::ALL_TRACKS_PLAYLIST_ID) {
738
			$playlist = $this->getAllTracksPlaylist();
739
		} else {
740
			$playlist = $this->playlistBusinessLayer->find($filter, $userId);
741
		}
742
		return $this->renderPlaylists([$playlist]);
743
	}
744
745
	/**
746
	 * @AmpacheAPI
747
	 */
748
	protected function playlist_songs(int $filter, int $limit, int $offset=0, bool $random=false) : array {
749
		// In random mode, the pagination is handled manually after fetching the songs. Declare $rndLimit and $rndOffset
750
		// regardless of the random mode because PHPStan and Scrutinizer are not smart enough to otherwise know that they
751
		// are guaranteed to be defined in the second random block in the end of this function.
752
		$rndLimit = $limit;
753
		$rndOffset = $offset;
754
		if ($random) {
755
			$limit = null;
756
			$offset = null;
757
		}
758
759
		$userId = $this->session->getUserId();
760
		if ($filter == self::ALL_TRACKS_PLAYLIST_ID) {
761
			$tracks = $this->trackBusinessLayer->findAll($userId, SortBy::Parent, $limit, $offset);
762
			foreach ($tracks as $index => &$track) {
763
				$track->setNumberOnPlaylist($index + 1);
764
			}
765
		} else {
766
			$tracks = $this->playlistBusinessLayer->getPlaylistTracks($filter, $userId, $limit, $offset);
767
		}
768
769
		if ($random) {
770
			$indices = $this->random->getIndices(\count($tracks), $rndOffset, $rndLimit, $userId, 'ampache_playlist_songs');
771
			$tracks = Util::arrayMultiGet($tracks, $indices);
772
		}
773
774
		return $this->renderSongs($tracks);
775
	}
776
777
	/**
778
	 * @AmpacheAPI
779
	 */
780
	protected function playlist_create(string $name) : array {
781
		$playlist = $this->playlistBusinessLayer->create($name, $this->session->getUserId());
782
		return $this->renderPlaylists([$playlist]);
783
	}
784
785
	/**
786
	 * @AmpacheAPI
787
	 *
788
	 * @param int $filter Playlist ID
789
	 * @param ?string $name New name for the playlist
790
	 * @param ?string $items Track IDs
791
	 * @param ?string $tracks 1-based indices of the tracks
792
	 */
793
	protected function playlist_edit(int $filter, ?string $name, ?string $items, ?string $tracks) : array {
794
		$edited = false;
795
		$userId = $this->session->getUserId();
796
		$playlist = $this->playlistBusinessLayer->find($filter, $userId);
797
798
		if (!empty($name)) {
799
			$playlist->setName($name);
800
			$edited = true;
801
		}
802
803
		$newTrackIds = Util::explode(',', $items);
804
		$newTrackOrdinals = Util::explode(',', $tracks);
805
806
		if (\count($newTrackIds) != \count($newTrackOrdinals)) {
807
			throw new AmpacheException("Arguments 'items' and 'tracks' must contain equal amount of elements", 400);
808
		} elseif (\count($newTrackIds) > 0) {
809
			$trackIds = $playlist->getTrackIdsAsArray();
810
811
			for ($i = 0, $count = \count($newTrackIds); $i < $count; ++$i) {
812
				$trackId = $newTrackIds[$i];
813
				if (!$this->trackBusinessLayer->exists($trackId, $userId)) {
814
					throw new AmpacheException("Invalid song ID $trackId", 404);
815
				}
816
				$trackIds[$newTrackOrdinals[$i]-1] = $trackId;
817
			}
818
819
			$playlist->setTrackIdsFromArray($trackIds);
820
			$edited = true;
821
		}
822
823
		if ($edited) {
824
			$this->playlistBusinessLayer->update($playlist);
825
			return ['success' => 'playlist changes saved'];
826
		} else {
827
			throw new AmpacheException('Nothing was changed', 400);
828
		}
829
	}
830
831
	/**
832
	 * @AmpacheAPI
833
	 */
834
	protected function playlist_delete(int $filter) : array {
835
		$this->playlistBusinessLayer->delete($filter, $this->session->getUserId());
836
		return ['success' => 'playlist deleted'];
837
	}
838
839
	/**
840
	 * @AmpacheAPI
841
	 */
842
	protected function playlist_add_song(int $filter, int $song, bool $check=false) : array {
843
		$userId = $this->session->getUserId();
844
		if (!$this->trackBusinessLayer->exists($song, $userId)) {
845
			throw new AmpacheException("Invalid song ID $song", 404);
846
		}
847
848
		$playlist = $this->playlistBusinessLayer->find($filter, $userId);
849
		$trackIds = $playlist->getTrackIdsAsArray();
850
851
		if ($check && \in_array($song, $trackIds)) {
852
			throw new AmpacheException("Can't add a duplicate item when check is enabled", 400);
853
		}
854
855
		$trackIds[] = $song;
856
		$playlist->setTrackIdsFromArray($trackIds);
857
		$this->playlistBusinessLayer->update($playlist);
858
		return ['success' => 'song added to playlist'];
859
	}
860
861
	/**
862
	 * @AmpacheAPI
863
	 */
864
	protected function playlist_add(int $filter, int $id, string $type) : array {
865
		$userId = $this->session->getUserId();
866
867
		if (!$this->getBusinessLayer($type)->exists($id, $userId)) {
868
			throw new AmpacheException("Invalid $type ID $id", 404);
869
		}
870
871
		$playlist = $this->playlistBusinessLayer->find($filter, $userId);
872
873
		$trackIds = $playlist->getTrackIdsAsArray();
874
		$newIds = $this->trackIdsForEntity($id, $type);
875
		$trackIds = \array_merge($trackIds, $newIds);
876
877
		$playlist->setTrackIdsFromArray($trackIds);
878
		$this->playlistBusinessLayer->update($playlist);
879
		return ['success' => "songs added to playlist"];
880
	}
881
882
	/**
883
	 * @AmpacheAPI
884
	 *
885
	 * @param int $filter Playlist ID
886
	 * @param ?int $song Track ID
887
	 * @param ?int $track 1-based index of the track
888
	 * @param ?int $clear Value 1 erases all the songs from the playlist
889
	 */
890
	protected function playlist_remove_song(int $filter, ?int $song, ?int $track, ?int $clear) : array {
891
		$playlist = $this->playlistBusinessLayer->find($filter, $this->session->getUserId());
892
893
		if ($clear === 1) {
894
			$trackIds = [];
895
			$message = 'all songs removed from playlist';
896
		} elseif ($song !== null) {
897
			$trackIds = $playlist->getTrackIdsAsArray();
898
			if (!\in_array($song, $trackIds)) {
899
				throw new AmpacheException("Song $song not found in playlist", 404);
900
			}
901
			$trackIds = Util::arrayDiff($trackIds, [$song]);
902
			$message = 'song removed from playlist';
903
		} elseif ($track !== null) {
904
			$trackIds = $playlist->getTrackIdsAsArray();
905
			if ($track < 1 || $track > \count($trackIds)) {
906
				throw new AmpacheException("Track ordinal $track is out of bounds", 404);
907
			}
908
			unset($trackIds[$track-1]);
909
			$message = 'song removed from playlist';
910
		} else {
911
			throw new AmpacheException("One of the arguments 'clear', 'song', 'track' is required", 400);
912
		}
913
914
		$playlist->setTrackIdsFromArray($trackIds);
915
		$this->playlistBusinessLayer->update($playlist);
916
		return ['success' => $message];
917
	}
918
919
	/**
920
	 * @AmpacheAPI
921
	 */
922
	protected function playlist_generate(
923
			?string $filter, ?int $album, ?int $artist, ?int $flag,
924
			int $limit, int $offset=0, string $mode='random', string $format='song') : array {
925
926
		$tracks = $this->findEntities($this->trackBusinessLayer, $filter, false); // $limit and $offset are applied later
927
928
		// filter the found tracks according to the additional requirements
929
		if ($album !== null) {
930
			$tracks = \array_filter($tracks, function ($track) use ($album) {
931
				return ($track->getAlbumId() == $album);
932
			});
933
		}
934
		if ($artist !== null) {
935
			$tracks = \array_filter($tracks, function ($track) use ($artist) {
936
				return ($track->getArtistId() == $artist);
937
			});
938
		}
939
		if ($flag == 1) {
940
			$tracks = \array_filter($tracks, function ($track) {
941
				return ($track->getStarred() !== null);
942
			});
943
		}
944
		// After filtering, there may be "holes" between the array indices. Reindex the array.
945
		$tracks = \array_values($tracks);
946
947
		if ($mode == 'random') {
948
			$userId = $this->session->getUserId();
949
			$indices = $this->random->getIndices(\count($tracks), $offset, $limit, $userId, 'ampache_playlist_generate');
950
			$tracks = Util::arrayMultiGet($tracks, $indices);
951
		} else { // 'recent', 'forgotten', 'unplayed'
952
			throw new AmpacheException("Mode '$mode' is not supported", 400);
953
		}
954
955
		switch ($format) {
956
			case 'song':
957
				return $this->renderSongs($tracks);
958
			case 'index':
959
				return $this->renderSongsIndex($tracks);
960
			case 'id':
961
				return $this->renderEntityIds($tracks);
962
			default:
963
				throw new AmpacheException("Format '$format' is not supported", 400);
964
		}
965
	}
966
967
	/**
968
	 * @AmpacheAPI
969
	 */
970
	protected function podcasts(?string $filter, ?string $include, int $limit, int $offset=0, bool $exact=false) : array {
971
		$channels = $this->findEntities($this->podcastChannelBusinessLayer, $filter, $exact, $limit, $offset);
972
973
		if ($include === 'episodes') {
974
			$this->injectEpisodesToChannels($channels);
975
		}
976
977
		return $this->renderPodcastChannels($channels);
978
	}
979
980
	/**
981
	 * @AmpacheAPI
982
	 */
983
	protected function podcast(int $filter, ?string $include) : array {
984
		$userId = $this->session->getUserId();
985
		$channel = $this->podcastChannelBusinessLayer->find($filter, $userId);
986
987
		if ($include === 'episodes') {
988
			$channel->setEpisodes($this->podcastEpisodeBusinessLayer->findAllByChannel($filter, $userId));
989
		}
990
991
		return $this->renderPodcastChannels([$channel]);
992
	}
993
994
	/**
995
	 * @AmpacheAPI
996
	 */
997
	protected function podcast_create(string $url) : array {
998
		$userId = $this->session->getUserId();
999
		$result = $this->podcastService->subscribe($url, $userId);
1000
1001
		switch ($result['status']) {
1002
			case PodcastService::STATUS_OK:
1003
				return $this->renderPodcastChannels([$result['channel']]);
1004
			case PodcastService::STATUS_INVALID_URL:
1005
				throw new AmpacheException("Invalid URL $url", 400);
1006
			case PodcastService::STATUS_INVALID_RSS:
1007
				throw new AmpacheException("The document at URL $url is not a valid podcast RSS feed", 400);
1008
			case PodcastService::STATUS_ALREADY_EXISTS:
1009
				throw new AmpacheException('User already has this podcast channel subscribed', 400);
1010
			default:
1011
				throw new AmpacheException("Unexpected status code {$result['status']}", 400);
1012
		}
1013
	}
1014
1015
	/**
1016
	 * @AmpacheAPI
1017
	 */
1018
	protected function podcast_delete(int $filter) : array {
1019
		$userId = $this->session->getUserId();
1020
		$status = $this->podcastService->unsubscribe($filter, $userId);
1021
1022
		switch ($status) {
1023
			case PodcastService::STATUS_OK:
1024
				return ['success' => 'podcast deleted'];
1025
			case PodcastService::STATUS_NOT_FOUND:
1026
				throw new AmpacheException('Channel to be deleted not found', 404);
1027
			default:
1028
				throw new AmpacheException("Unexpected status code $status", 400);
1029
		}
1030
	}
1031
1032
	/**
1033
	 * @AmpacheAPI
1034
	 */
1035
	protected function podcast_episodes(int $filter, int $limit, int $offset=0) : array {
1036
		$userId = $this->session->getUserId();
1037
		$episodes = $this->podcastEpisodeBusinessLayer->findAllByChannel($filter, $userId, $limit, $offset);
1038
		return $this->renderPodcastEpisodes($episodes);
1039
	}
1040
1041
	/**
1042
	 * @AmpacheAPI
1043
	 */
1044
	protected function podcast_episode(int $filter) : array {
1045
		$userId = $this->session->getUserId();
1046
		$episode = $this->podcastEpisodeBusinessLayer->find($filter, $userId);
1047
		return $this->renderPodcastEpisodes([$episode]);
1048
	}
1049
1050
	/**
1051
	 * @AmpacheAPI
1052
	 */
1053
	protected function update_podcast(int $id) : array {
1054
		$userId = $this->session->getUserId();
1055
		$result = $this->podcastService->updateChannel($id, $userId);
1056
1057
		switch ($result['status']) {
1058
			case PodcastService::STATUS_OK:
1059
				$message = $result['updated'] ? 'channel was updated from the source' : 'no changes found';
1060
				return ['success' => $message];
1061
			case PodcastService::STATUS_NOT_FOUND:
1062
				throw new AmpacheException('Channel to be updated not found', 404);
1063
			case PodcastService::STATUS_INVALID_URL:
1064
				throw new AmpacheException('failed to read from the channel URL', 400);
1065
			case PodcastService::STATUS_INVALID_RSS:
1066
				throw new AmpacheException('the document at the channel URL is not a valid podcast RSS feed', 400);
1067
			default:
1068
				throw new AmpacheException("Unexpected status code {$result['status']}", 400);
1069
		}
1070
	}
1071
1072
	/**
1073
	 * @AmpacheAPI
1074
	 */
1075
	protected function live_streams(?string $filter, int $limit, int $offset=0, bool $exact=false) : array {
1076
		$stations = $this->findEntities($this->radioStationBusinessLayer, $filter, $exact, $limit, $offset);
1077
		return $this->renderLiveStreams($stations);
1078
	}
1079
1080
	/**
1081
	 * @AmpacheAPI
1082
	 */
1083
	protected function live_stream(int $filter) : array {
1084
		$station = $this->radioStationBusinessLayer->find($filter, $this->session->getUserId());
1085
		return $this->renderLiveStreams([$station]);
1086
	}
1087
1088
	/**
1089
	 * @AmpacheAPI
1090
	 */
1091
	protected function live_stream_create(string $name, string $url, ?string $site_url) : array {
1092
		$station = $this->radioStationBusinessLayer->create($this->session->getUserId(), $name, $url, $site_url);
1093
		return $this->renderLiveStreams([$station]);
1094
	}
1095
1096
	/**
1097
	 * @AmpacheAPI
1098
	 */
1099
	protected function live_stream_delete(int $filter) : array {
1100
		$this->radioStationBusinessLayer->delete($filter, $this->session->getUserId());
1101
		return ['success' => "Deleted live stream: $filter"];
1102
	}
1103
1104
	/**
1105
	 * @AmpacheAPI
1106
	 */
1107
	protected function live_stream_edit(int $filter, ?string $name, ?string $url, ?string $site_url) : array {
1108
		$station = $this->radioStationBusinessLayer->find($filter, $this->session->getUserId());
1109
1110
		if ($name !== null) {
1111
			$station->setName($name);
1112
		}
1113
		if ($url !== null) {
1114
			$station->setStreamUrl($url);
1115
		}
1116
		if ($site_url !== null) {
1117
			$station->setHomeUrl($site_url);
1118
		}
1119
		$station = $this->radioStationBusinessLayer->update($station);
1120
1121
		return $this->renderLiveStreams([$station]);
1122
	}
1123
1124
	/**
1125
	 * @AmpacheAPI
1126
	 */
1127
	protected function tags(?string $filter, int $limit, int $offset=0, bool $exact=false) : array {
1128
		$genres = $this->findEntities($this->genreBusinessLayer, $filter, $exact, $limit, $offset);
1129
		return $this->renderTags($genres);
1130
	}
1131
1132
	/**
1133
	 * @AmpacheAPI
1134
	 */
1135
	protected function tag(int $filter) : array {
1136
		$userId = $this->session->getUserId();
1137
		$genre = $this->genreBusinessLayer->find($filter, $userId);
1138
		return $this->renderTags([$genre]);
1139
	}
1140
1141
	/**
1142
	 * @AmpacheAPI
1143
	 */
1144
	protected function tag_artists(int $filter, int $limit, int $offset=0) : array {
1145
		$userId = $this->session->getUserId();
1146
		$artists = $this->artistBusinessLayer->findAllByGenre($filter, $userId, $limit, $offset);
1147
		return $this->renderArtists($artists);
1148
	}
1149
1150
	/**
1151
	 * @AmpacheAPI
1152
	 */
1153
	protected function tag_albums(int $filter, int $limit, int $offset=0) : array {
1154
		$userId = $this->session->getUserId();
1155
		$albums = $this->albumBusinessLayer->findAllByGenre($filter, $userId, $limit, $offset);
1156
		return $this->renderAlbums($albums);
1157
	}
1158
1159
	/**
1160
	 * @AmpacheAPI
1161
	 */
1162
	protected function tag_songs(int $filter, int $limit, int $offset=0) : array {
1163
		$userId = $this->session->getUserId();
1164
		$tracks = $this->trackBusinessLayer->findAllByGenre($filter, $userId, $limit, $offset);
1165
		return $this->renderSongs($tracks);
1166
	}
1167
1168
	/**
1169
	 * @AmpacheAPI
1170
	 */
1171
	protected function genres(?string $filter, int $limit, int $offset=0, bool $exact=false) : array {
1172
		$genres = $this->findEntities($this->genreBusinessLayer, $filter, $exact, $limit, $offset);
1173
		return $this->renderGenres($genres);
1174
	}
1175
1176
	/**
1177
	 * @AmpacheAPI
1178
	 */
1179
	protected function genre(int $filter) : array {
1180
		$userId = $this->session->getUserId();
1181
		$genre = $this->genreBusinessLayer->find($filter, $userId);
1182
		return $this->renderGenres([$genre]);
1183
	}
1184
1185
	/**
1186
	 * @AmpacheAPI
1187
	 */
1188
	protected function genre_artists(?int $filter, int $limit, int $offset=0) : array {
1189
		if ($filter === null) {
1190
			return $this->artists(null, null, null, $limit, $offset);
1191
		} else {
1192
			return $this->tag_artists($filter, $limit, $offset);
1193
		}
1194
	}
1195
1196
	/**
1197
	 * @AmpacheAPI
1198
	 */
1199
	protected function genre_albums(?int $filter, int $limit, int $offset=0) : array {
1200
		if ($filter === null) {
1201
			return $this->albums(null, null, null, $limit, $offset);
1202
		} else {
1203
			return $this->tag_albums($filter, $limit, $offset);
1204
		}
1205
	}
1206
1207
	/**
1208
	 * @AmpacheAPI
1209
	 */
1210
	protected function genre_songs(?int $filter, int $limit, int $offset=0) : array {
1211
		if ($filter === null) {
1212
			return $this->songs(null, null, null, $limit, $offset);
1213
		} else {
1214
			return $this->tag_songs($filter, $limit, $offset);
1215
		}
1216
	}
1217
1218
	/**
1219
	 * @AmpacheAPI
1220
	 */
1221
	protected function bookmarks(int $include=0) : array {
1222
		$bookmarks = $this->bookmarkBusinessLayer->findAll($this->session->getUserId());
1223
		return $this->renderBookmarks($bookmarks, $include);
1224
	}
1225
1226
	/**
1227
	 * @AmpacheAPI
1228
	 */
1229
	protected function bookmark(int $filter, int $include=0) : array {
1230
		$bookmark = $this->bookmarkBusinessLayer->find($filter, $this->session->getUserId());
1231
		return $this->renderBookmarks([$bookmark], $include);
1232
	}
1233
1234
	/**
1235
	 * @AmpacheAPI
1236
	 */
1237
	protected function get_bookmark(int $filter, string $type, int $include=0) : array {
1238
		$entryType = self::mapBookmarkType($type);
1239
		$bookmark = $this->bookmarkBusinessLayer->findByEntry($entryType, $filter, $this->session->getUserId());
1240
		return $this->renderBookmarks([$bookmark], $include);
1241
	}
1242
1243
	/**
1244
	 * @AmpacheAPI
1245
	 */
1246
	protected function bookmark_create(int $filter, string $type, int $position, ?string $client, int $include=0) : array {
1247
		// Note: the optional argument 'date' is not supported and is disregarded
1248
		$entryType = self::mapBookmarkType($type);
1249
		$position *= 1000; // seconds to milliseconds
1250
		$bookmark = $this->bookmarkBusinessLayer->addOrUpdate($this->session->getUserId(), $entryType, $filter, $position, $client);
1251
		return $this->renderBookmarks([$bookmark], $include);
1252
	}
1253
1254
	/**
1255
	 * @AmpacheAPI
1256
	 */
1257
	protected function bookmark_edit(int $filter, string $type, int $position, ?string $client, int $include=0) : array {
1258
		// Note: the optional argument 'date' is not supported and is disregarded
1259
		$entryType = self::mapBookmarkType($type);
1260
		$bookmark = $this->bookmarkBusinessLayer->findByEntry($entryType, $filter, $this->session->getUserId());
1261
		$bookmark->setPosition($position * 1000); // seconds to milliseconds
1262
		if ($client !== null) {
1263
			$bookmark->setComment($client);
1264
		}
1265
		$bookmark = $this->bookmarkBusinessLayer->update($bookmark);
1266
		return $this->renderBookmarks([$bookmark], $include);
1267
	}
1268
1269
	/**
1270
	 * @AmpacheAPI
1271
	 */
1272
	protected function bookmark_delete(int $filter, string $type) : array {
1273
		$entryType = self::mapBookmarkType($type);
1274
		$bookmark = $this->bookmarkBusinessLayer->findByEntry($entryType, $filter, $this->session->getUserId());
1275
		$this->bookmarkBusinessLayer->delete($bookmark->getId(), $bookmark->getUserId());
1276
		return ['success' => "Deleted Bookmark: $type $filter"];
1277
	}
1278
1279
	/**
1280
	 * @AmpacheAPI
1281
	 */
1282
	protected function advanced_search(int $limit, int $offset=0, string $type='song', string $operator='and', bool $random=false) : array {
1283
		// get all the rule parameters as passed on the HTTP call
1284
		$rules = self::advSearchGetRuleParams($this->request->getParams());
1285
1286
		// apply some conversions on the rules
1287
		foreach ($rules as &$rule) {
1288
			$rule['rule'] = self::advSearchResolveRuleAlias($rule['rule']);
1289
			$rule['operator'] = self::advSearchInterpretOperator($rule['operator'], $rule['rule']);
1290
			$rule['input'] = self::advSearchConvertInput($rule['input'], $rule['rule']);
1291
		}
1292
1293
		// types 'album_artist' and 'song_artist' are just 'artist' searches with some extra conditions
1294
		if ($type == 'album_artist') {
1295
			$rules[] = ['rule' => 'album_count', 'operator' => '>', 'input' => '0'];
1296
			$type = 'artist';
1297
		} elseif ($type == 'song_artist') {
1298
			$rules[] = ['rule' => 'song_count', 'operator' => '>', 'input' => '0'];
1299
			$type = 'artist';
1300
		}
1301
1302
		try {
1303
			$businessLayer = $this->getBusinessLayer($type);
1304
			$userId = $this->session->getUserId();
1305
			$entities = $businessLayer->findAllAdvanced($operator, $rules, $userId, SortBy::Name, $random ? $this->random : null, $limit, $offset);
1306
		} catch (BusinessLayerException $e) {
1307
			throw new AmpacheException($e->getMessage(), 400);
1308
		}
1309
		
1310
		return $this->renderEntities($entities, $type);
1311
	}
1312
1313
	/**
1314
	 * @AmpacheAPI
1315
	 */
1316
	protected function search(int $limit, int $offset=0, string $type='song', string $operator='and', bool $random=false) : array {
1317
		// this is an alias
1318
		return $this->advanced_search($limit, $offset, $type, $operator, $random);
1319
	}
1320
1321
	/**
1322
	 * @AmpacheAPI
1323
	 */
1324
	protected function flag(string $type, int $id, bool $flag) : array {
1325
		if (!\in_array($type, ['song', 'album', 'artist', 'podcast', 'podcast_episode', 'playlist'])) {
1326
			throw new AmpacheException("Unsupported type $type", 400);
1327
		}
1328
1329
		$userId = $this->session->getUserId();
1330
		$businessLayer = $this->getBusinessLayer($type);
1331
		if ($flag) {
1332
			$modifiedCount = $businessLayer->setStarred([$id], $userId);
1333
			$message = "flag ADDED to $type $id";
1334
		} else {
1335
			$modifiedCount = $businessLayer->unsetStarred([$id], $userId);
1336
			$message = "flag REMOVED from $type $id";
1337
		}
1338
1339
		if ($modifiedCount > 0) {
1340
			return ['success' => $message];
1341
		} else {
1342
			throw new AmpacheException("The $type $id was not found", 404);
1343
		}
1344
	}
1345
1346
	/**
1347
	 * @AmpacheAPI
1348
	 */
1349
	protected function rate(string $type, int $id, int $rating) : array {
1350
		$rating = Util::limit($rating, 0, 5);
1351
		$userId = $this->session->getUserId();
1352
		$businessLayer = $this->getBusinessLayer($type);
1353
		$entity = $businessLayer->find($id, $userId);
1354
		if (\property_exists($entity, 'rating')) {
1355
			// Scrutinizer and PHPStan don't understand the connection between the property 'rating' and the method 'setRating'
1356
			$entity->/** @scrutinizer ignore-call */setRating($rating); // @phpstan-ignore-line
1357
			$businessLayer->update($entity);
1358
		} else {
1359
			throw new AmpacheException("Unsupported type $type", 400);
1360
		}
1361
1362
		return ['success' => "rating set to $rating for $type $id"];
1363
	}
1364
1365
	/**
1366
	 * @AmpacheAPI
1367
	 */
1368
	protected function record_play(int $id, ?int $date) : array {
1369
		$timeOfPlay = ($date === null) ? null : new \DateTime('@' . $date);
1370
		$this->trackBusinessLayer->recordTrackPlayed($id, $this->session->getUserId(), $timeOfPlay);
1371
		return ['success' => 'play recorded'];
1372
	}
1373
1374
	/**
1375
	 * @AmpacheAPI
1376
	 */
1377
	protected function scrobble(string $song, string $artist, string $album, ?int $date) : array {
1378
		// arguments songmbid, artistmbid, and albummbid not supported for now
1379
		$matching = $this->trackBusinessLayer->findAllByNameArtistOrAlbum($song, $artist, $album, $this->session->getUserId());
1380
		if (\count($matching) === 0) {
1381
			throw new AmpacheException('Song matching the criteria was not found', 404);
1382
		} else if (\count($matching) > 1) {
1383
			throw new AmpacheException('Multiple songs matched the criteria, nothing recorded', 400);
1384
		}
1385
		return $this->record_play($matching[0]->getId(), $date);
1386
	}
1387
1388
	/**
1389
	 * @AmpacheAPI
1390
	 */
1391
	protected function user(?string $username) : array {
1392
		if (!empty($username) && $username !== $this->session->getUserId()) {
1393
			throw new AmpacheException("Getting info of other users is forbidden", 403);
1394
		}
1395
		$user = $this->userManager->get($this->session->getUserId());
1396
1397
		return [
1398
			'id' => $user->getUID(),
1399
			'username' => $user->getUID(),
1400
			'fullname' => $user->getDisplayName(),
1401
			'auth' => '',
1402
			'email' => $user->getEMailAddress(),
1403
			'access' => 25,
1404
			'streamtoken' => null,
1405
			'fullname_public' => true,
1406
			'validation' => null,
1407
			'disabled' => !$user->isEnabled(),
1408
			'create_date' => null,
1409
			'last_seen' => null,
1410
			'website' => null,
1411
			'state' => null,
1412
			'city' => null,
1413
			'art' => $this->urlGenerator->linkToRouteAbsolute('core.avatar.getAvatar', ['userId' => $user->getUID(), 'size' => 64]),
1414
			'has_art' => true
1415
		];
1416
	}
1417
1418
	/**
1419
	 * @AmpacheAPI
1420
	 */
1421
	protected function user_preferences() : array {
1422
		return ['preference' => AmpachePreferences::getAll()];
1423
	}
1424
1425
	/**
1426
	 * @AmpacheAPI
1427
	 */
1428
	protected function user_preference(string $filter) : array {
1429
		$pref = AmpachePreferences::get($filter);
1430
		if ($pref === null) {
1431
			throw new AmpacheException("Not Found: $filter", 400);
1432
		} else {
1433
			return ['preference' => [$pref]];
1434
		}
1435
	}
1436
1437
	/**
1438
	 * @AmpacheAPI
1439
	 */
1440
	protected function system_preferences() : array {
1441
		return $this->user_preferences();
1442
	}
1443
1444
	/**
1445
	 * @AmpacheAPI
1446
	 */
1447
	protected function system_preference(string $filter) : array {
1448
		return $this->user_preference($filter);
1449
	}
1450
1451
	/**
1452
	 * @AmpacheAPI
1453
	 */
1454
	protected function download(int $id, string $type='song') : Response {
1455
		// request param `format` is ignored
1456
		$userId = $this->session->getUserId();
1457
1458
		if ($type === 'song') {
1459
			try {
1460
				$track = $this->trackBusinessLayer->find($id, $userId);
1461
			} catch (BusinessLayerException $e) {
1462
				return new ErrorResponse(Http::STATUS_NOT_FOUND, $e->getMessage());
1463
			}
1464
1465
			$file = $this->librarySettings->getFolder($userId)->getById($track->getFileId())[0] ?? null;
1466
1467
			if ($file instanceof \OCP\Files\File) {
1468
				return new FileStreamResponse($file);
1469
			} else {
1470
				return new ErrorResponse(Http::STATUS_NOT_FOUND);
1471
			}
1472
		} elseif ($type === 'podcast' || $type === 'podcast_episode') { // there's a difference between APIv4 and APIv5
1473
			$episode = $this->podcastEpisodeBusinessLayer->find($id, $userId);
1474
			return new RedirectResponse($episode->getStreamUrl());
1475
		} elseif ($type === 'playlist') {
1476
			$songIds = ($id === self::ALL_TRACKS_PLAYLIST_ID)
1477
				? $this->trackBusinessLayer->findAllIds($userId)
1478
				: $this->playlistBusinessLayer->find($id, $userId)->getTrackIdsAsArray();
1479
			$randomId = Random::pickItem($songIds);
1480
			if ($randomId === null) {
1481
				throw new AmpacheException("The playlist $id is empty", 404);
1482
			} else {
1483
				return $this->download((int)$randomId);
1484
			}
1485
		} else {
1486
			throw new AmpacheException("Unsupported type '$type'", 400);
1487
		}
1488
	}
1489
1490
	/**
1491
	 * @AmpacheAPI
1492
	 */
1493
	protected function stream(int $id, ?int $offset, string $type='song') : Response {
1494
		// request params `bitrate`, `format`, and `length` are ignored
1495
1496
		// This is just a dummy implementation. We don't support transcoding or streaming
1497
		// from a time offset.
1498
		// All the other unsupported arguments are just ignored, but a request with an offset
1499
		// is responded with an error. This is because the client would probably work in an
1500
		// unexpected way if it thinks it's streaming from offset but actually it is streaming
1501
		// from the beginning of the file. Returning an error gives the client a chance to fallback
1502
		// to other methods of seeking.
1503
		if ($offset !== null) {
1504
			throw new AmpacheException('Streaming with time offset is not supported', 400);
1505
		}
1506
1507
		return $this->download($id, $type);
1508
	}
1509
1510
	/**
1511
	 * @AmpacheAPI
1512
	 */
1513
	protected function get_art(string $type, int $id) : Response {
1514
		if (!\in_array($type, ['song', 'album', 'artist', 'podcast', 'playlist'])) {
1515
			throw new AmpacheException("Unsupported type $type", 400);
1516
		}
1517
1518
		if ($type === 'song') {
1519
			// map song to its parent album
1520
			$id = $this->trackBusinessLayer->find($id, $this->session->getUserId())->getAlbumId();
1521
			$type = 'album';
1522
		}
1523
1524
		return $this->getCover($id, $this->getBusinessLayer($type));
1525
	}
1526
1527
	/********************
1528
	 * Helper functions *
1529
	 ********************/
1530
1531
	private function getBusinessLayer(string $type) : BusinessLayer {
1532
		switch ($type) {
1533
			case 'song':			return $this->trackBusinessLayer;
1534
			case 'album':			return $this->albumBusinessLayer;
1535
			case 'artist':			return $this->artistBusinessLayer;
1536
			case 'playlist':		return $this->playlistBusinessLayer;
1537
			case 'podcast':			return $this->podcastChannelBusinessLayer;
1538
			case 'podcast_episode':	return $this->podcastEpisodeBusinessLayer;
1539
			case 'live_stream':		return $this->radioStationBusinessLayer;
1540
			case 'tag':				return $this->genreBusinessLayer;
1541
			case 'genre':			return $this->genreBusinessLayer;
1542
			case 'bookmark':		return $this->bookmarkBusinessLayer;
1543
			default:				throw new AmpacheException("Unsupported type $type", 400);
1544
		}
1545
	}
1546
1547
	private static function getChildEntityType(string $type) : ?string {
1548
		switch ($type) {
1549
			case 'album':			return 'song';
1550
			case 'artist':			return 'album';
1551
			case 'album_artist':	return 'album';
1552
			case 'song_artist':		return 'album';
1553
			case 'playlist':		return 'song';
1554
			case 'podcast':			return 'podcast_episode';
1555
			default:				return null;
1556
		}
1557
	}
1558
1559
	private function renderEntities(array $entities, string $type) : array {
1560
		switch ($type) {
1561
			case 'song':			return $this->renderSongs($entities);
1562
			case 'album':			return $this->renderAlbums($entities);
1563
			case 'artist':			return $this->renderArtists($entities);
1564
			case 'playlist':		return $this->renderPlaylists($entities);
1565
			case 'podcast':			return $this->renderPodcastChannels($entities);
1566
			case 'podcast_episode':	return $this->renderPodcastEpisodes($entities);
1567
			case 'live_stream':		return $this->renderLiveStreams($entities);
1568
			case 'tag':				return $this->renderTags($entities);
1569
			case 'genre':			return $this->renderGenres($entities);
1570
			case 'bookmark':		return $this->renderBookmarks($entities);
1571
			default:				throw new AmpacheException("Unsupported type $type", 400);
1572
		}
1573
	}
1574
1575
	private function renderEntitiesIndex($entities, $type) : array {
1576
		switch ($type) {
1577
			case 'song':			return $this->renderSongsIndex($entities);
1578
			case 'album':			return $this->renderAlbumsIndex($entities);
1579
			case 'artist':			return $this->renderArtistsIndex($entities);
1580
			case 'playlist':		return $this->renderPlaylistsIndex($entities);
1581
			case 'podcast':			return $this->renderPodcastChannelsIndex($entities);
1582
			case 'podcast_episode':	return $this->renderPodcastEpisodesIndex($entities);
1583
			case 'live_stream':		return $this->renderLiveStreamsIndex($entities);
1584
			case 'tag':				return $this->renderTags($entities); // not part of the API spec
1585
			case 'genre':			return $this->renderGenres($entities); // not part of the API spec
1586
			case 'bookmark':		return $this->renderBookmarks($entities); // not part of the API spec
1587
			default:				throw new AmpacheException("Unsupported type $type", 400);
1588
		}
1589
	}
1590
1591
	private function trackIdsForEntity(int $id, string $type) : array {
1592
		$userId = $this->session->getUserId();
1593
		switch ($type) {
1594
			case 'song':
1595
				return [$id];
1596
			case 'album':
1597
				return Util::extractIds($this->trackBusinessLayer->findAllByAlbum($id, $userId));
1598
			case 'artist':
1599
				return Util::extractIds($this->trackBusinessLayer->findAllByArtist($id, $userId));
1600
			case 'playlist':
1601
				return $this->playlistBusinessLayer->find($id, $userId)->getTrackIdsAsArray();
1602
			default:
1603
				throw new AmpacheException("Unsupported type $type", 400);
1604
		}
1605
	}
1606
1607
	private static function mapBookmarkType(string $ampacheType) : int {
1608
		switch ($ampacheType) {
1609
			case 'song':			return Bookmark::TYPE_TRACK;
1610
			case 'podcast_episode':	return Bookmark::TYPE_PODCAST_EPISODE;
1611
			default:				throw new AmpacheException("Unsupported type $ampacheType", 400);
1612
		}
1613
	}
1614
1615
	private static function advSearchResolveRuleAlias(string $rule) : string {
1616
		switch ($rule) {
1617
			case 'name':					return 'title';
1618
			case 'song_title':				return 'song';
1619
			case 'album_title':				return 'album';
1620
			case 'artist_title':			return 'artist';
1621
			case 'podcast_title':			return 'podcast';
1622
			case 'podcast_episode_title':	return 'podcast_episode';
1623
			case 'album_artist_title':		return 'album_artist';
1624
			case 'song_artist_title':		return 'song_artist';
1625
			case 'tag':						return 'genre';
1626
			case 'song_tag':				return 'song_genre';
1627
			case 'album_tag':				return 'album_genre';
1628
			case 'artist_tag':				return 'artist_genre';
1629
			case 'no_tag':					return 'no_genre';
1630
			default:						return $rule;
1631
		}
1632
	}
1633
1634
	private static function advSearchGetRuleParams(array $urlParams) : array {
1635
		$rules = [];
1636
1637
		// read and organize the rule parameters
1638
		foreach ($urlParams as $key => $value) {
1639
			$parts = \explode('_', $key, 3);
1640
			if ($parts[0] == 'rule' && \count($parts) > 1) {
1641
				if (\count($parts) == 2) {
1642
					$rules[$parts[1]]['rule'] = $value;
1643
				} elseif ($parts[2] == 'operator') {
1644
					$rules[$parts[1]]['operator'] = (int)$value;
1645
				} elseif ($parts[2] == 'input') {
1646
					$rules[$parts[1]]['input'] = $value;
1647
				}
1648
			}
1649
		}
1650
1651
		// validate the rule parameters
1652
		if (\count($rules) === 0) {
1653
			throw new AmpacheException('At least one rule must be given', 400);
1654
		}
1655
		foreach ($rules as $rule) {
1656
			if (\count($rule) != 3) {
1657
				throw new AmpacheException('All rules must be given as triplet "rule_N", "rule_N_operator", "rule_N_input"', 400);
1658
			}
1659
		}
1660
1661
		return $rules;
1662
	}
1663
1664
	// NOTE: alias rule names should be resolved to their base form before calling this
1665
	private static function advSearchInterpretOperator(int $rule_operator, string $rule) : string {
1666
		// Operator mapping is different for text, numeric, date, boolean, and day rules
1667
1668
		$textRules = [
1669
			'anywhere', 'title', 'song', 'album', 'artist', 'podcast', 'podcast_episode', 'album_artist', 'song_artist',
1670
			'favorite', 'favorite_album', 'favorite_artist', 'genre', 'song_genre', 'album_genre', 'artist_genre',
1671
			'playlist_name', 'type', 'file', 'mbid', 'mbid_album', 'mbid_artist', 'mbid_song'
1672
		];
1673
		// text but no support planned: 'composer', 'summary', 'placeformed', 'release_type', 'release_status', 'barcode',
1674
		// 'catalog_number', 'label', 'comment', 'lyrics', 'username', 'category'
1675
1676
		$numericRules = [
1677
			'track', 'year', 'original_year', 'myrating', 'rating', 'songrating', 'albumrating', 'artistrating',
1678
			'played_times', 'album_count', 'song_count', 'time', 'bitrate'
1679
		];
1680
		// numeric but no support planned: 'yearformed', 'skipped_times', 'play_skip_ratio', 'image_height', 'image_width'
1681
1682
		$numericLimitRules = ['recent_played', 'recent_added', 'recent_updated'];
1683
1684
		$dateOrDayRules = ['added', 'updated', 'pubdate', 'last_play'];
1685
1686
		$booleanRules = [
1687
			'played', 'myplayed', 'myplayedalbum', 'myplayedartist', 'has_image', 'no_genre',
1688
			'my_flagged', 'my_flagged_album', 'my_flagged_artist'
1689
		];
1690
		// boolean but no support planned: 'smartplaylist', 'possible_duplicate', 'possible_duplicate_album'
1691
1692
		$booleanNumericRules = ['playlist'];
1693
		// boolean numeric but no support planned: 'license', 'state', 'catalog'
1694
1695
		if (\in_array($rule, $textRules)) {
1696
			switch ($rule_operator) {
1697
				case 0: return 'contain';		// contains
1698
				case 1: return 'notcontain';	// does not contain;
1699
				case 2: return 'start';			// starts with
1700
				case 3: return 'end';			// ends with;
1701
				case 4: return 'is';			// is
1702
				case 5: return 'isnot';			// is not
1703
				case 6: return 'sounds';		// sounds like
1704
				case 7: return 'notsounds';		// does not sound like
1705
				case 8: return 'regexp';		// matches regex
1706
				case 9: return 'notregexp';		// does not match regex
1707
				default: throw new AmpacheException("Search operator '$rule_operator' not supported for 'text' type rules", 400);
1708
			}
1709
		} elseif (\in_array($rule, $numericRules)) {
1710
			switch ($rule_operator) {
1711
				case 0: return '>=';
1712
				case 1: return '<=';
1713
				case 2: return '=';
1714
				case 3: return '!=';
1715
				case 4: return '>';
1716
				case 5: return '<';
1717
				default: throw new AmpacheException("Search operator '$rule_operator' not supported for 'numeric' type rules", 400);
1718
			}
1719
		} elseif (\in_array($rule, $numericLimitRules)) {
1720
			return 'limit';
1721
		} elseif (\in_array($rule, $dateOrDayRules)) {
1722
			switch ($rule_operator) {
1723
				case 0: return 'before';
1724
				case 1: return 'after';
1725
				default: throw new AmpacheException("Search operator '$rule_operator' not supported for 'date' or 'day' type rules", 400);
1726
			}
1727
		} elseif (\in_array($rule, $booleanRules)) {
1728
			switch ($rule_operator) {
1729
				case 0: return 'true';
1730
				case 1: return 'false';
1731
				default: throw new AmpacheException("Search operator '$rule_operator' not supported for 'boolean' type rules", 400);
1732
			}
1733
		} elseif (\in_array($rule, $booleanNumericRules)) {
1734
			switch ($rule_operator) {
1735
				case 0: return 'equal';
1736
				case 1: return 'ne';
1737
				default: throw new AmpacheException("Search operator '$rule_operator' not supported for 'boolean numeric' type rules", 400);
1738
			}
1739
		} else {
1740
			throw new AmpacheException("Search rule '$rule' not supported", 400);
1741
		}
1742
	}
1743
1744
	private static function advSearchConvertInput(string $input, string $rule) {
1745
		switch ($rule) {
1746
			case 'last_play':
1747
				// days diff to ISO date
1748
				$date = new \DateTime("$input days ago");
1749
				return $date->format(BaseMapper::SQL_DATE_FORMAT);
1750
			case 'time':
1751
				// minutes to seconds
1752
				return (string)(int)((float)$input * 60);
1753
			default:
1754
				return $input;
1755
		}
1756
	}
1757
1758
	private function getAllTracksPlaylist() : Playlist {
1759
		$pl = new class extends Playlist {
1760
			public $trackCount;
1761
			public function getTrackCount() : int {
1762
				return $this->trackCount;
1763
			}
1764
		};
1765
		$pl->id = self::ALL_TRACKS_PLAYLIST_ID;
1766
		$pl->name = $this->l10n->t('All tracks');
1767
		$pl->userId = $this->session->getUserId();
1768
		$pl->trackCount = $this->trackBusinessLayer->count($this->session->getUserId());
1769
1770
		return $pl;
1771
	}
1772
1773
	private function getCover(int $entityId, BusinessLayer $businessLayer) : Response {
1774
		$userId = $this->session->getUserId();
1775
		$userFolder = $this->librarySettings->getFolder($userId);
1776
1777
		try {
1778
			$entity = $businessLayer->find($entityId, $userId);
1779
			$coverData = $this->coverHelper->getCover($entity, $userId, $userFolder);
1780
			if ($coverData !== null) {
1781
				return new FileResponse($coverData);
1782
			}
1783
		} catch (BusinessLayerException $e) {
1784
			return new ErrorResponse(Http::STATUS_NOT_FOUND, 'entity not found');
1785
		}
1786
1787
		return new ErrorResponse(Http::STATUS_NOT_FOUND, 'entity has no cover');
1788
	}
1789
1790
	private static function parseTimeParameters(?string $add=null, ?string $update=null) : array {
1791
		// It's not documented, but Ampache supports also specifying date range on `add` and `update` parameters
1792
		// by using '/' as separator. If there is no such separator, then the value is used as a lower limit.
1793
		$add = Util::explode('/', $add);
1794
		$update = Util::explode('/', $update);
1795
		$addMin = $add[0] ?? null;
1796
		$addMax = $add[1] ?? null;
1797
		$updateMin = $update[0] ?? null;
1798
		$updateMax = $update[1] ?? null;
1799
1800
		return [$addMin, $addMax, $updateMin, $updateMax];
1801
	}
1802
1803
	private function findEntities(
1804
			BusinessLayer $businessLayer, ?string $filter, bool $exact, ?int $limit=null, ?int $offset=null, ?string $add=null, ?string $update=null) : array {
1805
1806
		$userId = $this->session->getUserId();
1807
1808
		list($addMin, $addMax, $updateMin, $updateMax) = self::parseTimeParameters($add, $update);
1809
1810
		if ($filter) {
1811
			$matchMode = $exact ? MatchMode::Exact : MatchMode::Substring;
1812
			return $businessLayer->findAllByName($filter, $userId, $matchMode, $limit, $offset, $addMin, $addMax, $updateMin, $updateMax);
1813
		} else {
1814
			return $businessLayer->findAll($userId, SortBy::Name, $limit, $offset, $addMin, $addMax, $updateMin, $updateMax);
1815
		}
1816
	}
1817
1818
	/**
1819
	 * @param PodcastChannel[] &$channels
1820
	 */
1821
	private function injectEpisodesToChannels(array &$channels) : void {
1822
		$userId = $this->session->getUserId();
1823
		$allChannelsIncluded = (\count($channels) === $this->podcastChannelBusinessLayer->count($userId));
1824
		$this->podcastService->injectEpisodes($channels, $userId, $allChannelsIncluded);
1825
	}
1826
1827
	private function createAmpacheActionUrl(string $action, int $id, ?string $type=null) : string {
1828
		$api = $this->jsonMode ? 'music.ampache.jsonApi' : 'music.ampache.xmlApi';
1829
		$auth = $this->session->getToken();
1830
		return $this->urlGenerator->linkToRouteAbsolute($api)
1831
				. "?action=$action&id=$id&auth=$auth"
1832
				. (!empty($type) ? "&type=$type" : '');
1833
	}
1834
1835
	private function createCoverUrl(Entity $entity) : string {
1836
		if ($entity instanceof Album) {
1837
			$type = 'album';
1838
		} elseif ($entity instanceof Artist) {
1839
			$type = 'artist';
1840
		} elseif ($entity instanceof Playlist) {
1841
			$type = 'playlist';
1842
		} else {
1843
			throw new AmpacheException('unexpected entity type for cover image', 500);
1844
		}
1845
1846
		// Scrutinizer doesn't understand that the if-else above guarantees that getCoverFileId() may be called only on Album or Artist
1847
		if ($type === 'playlist' || $entity->/** @scrutinizer ignore-call */getCoverFileId()) {
1848
			$id = $entity->getId();
1849
			$token = $this->imageService->getToken($type, $id, $this->session->getAmpacheUserId());
1850
			return $this->urlGenerator->linkToRouteAbsolute('music.ampacheImage.image') . "?object_type=$type&object_id=$id&token=$token";
1851
		} else {
1852
			return '';
1853
		}
1854
	}
1855
1856
	private static function indexIsWithinOffsetAndLimit(int $index, ?int $offset, ?int $limit) : bool {
1857
		$offset = \intval($offset); // missing offset is interpreted as 0-offset
1858
		return ($limit === null) || ($index >= $offset && $index < $offset + $limit);
1859
	}
1860
1861
	private function prefixAndBaseName(?string $name) : array {
1862
		$parts = ['prefix' => null, 'basename' => $name];
1863
1864
		if ($name !== null) {
1865
			foreach ($this->namePrefixes as $prefix) {
1866
				if (Util::startsWith($name, $prefix . ' ', /*ignoreCase=*/true)) {
1867
					$parts['prefix'] = $prefix;
1868
					$parts['basename'] = \substr($name, \strlen($prefix) + 1);
1869
					break;
1870
				}
1871
			}
1872
		}
1873
1874
		return $parts;
1875
	}
1876
1877
	private function renderAlbumOrArtistRef(int $id, string $name) : array {
1878
		if ($this->apiMajorVersion() > 5) {
1879
			return [
1880
				'id' => (string)$id,
1881
				'name' => $name,
1882
			] + $this->prefixAndBaseName($name);
1883
		} else {
1884
			return [
1885
				'id' => (string)$id,
1886
				'text' => $name
1887
			];
1888
		}
1889
	}
1890
1891
	/**
1892
	 * @param Artist[] $artists
1893
	 */
1894
	private function renderArtists(array $artists) : array {
1895
		$userId = $this->session->getUserId();
1896
		$genreMap = Util::createIdLookupTable($this->genreBusinessLayer->findAll($userId));
1897
		$genreKey = $this->genreKey();
1898
		// In APIv3-4, the properties 'albums' and 'songs' were used for the album/song count in case the inclusion of the relevant
1899
		// child objects wasn't requested. APIv5+ has the dedicated properties 'albumcount' and 'songcount' for this purpose.
1900
		$oldCountApi = ($this->apiMajorVersion() < 5);
1901
1902
		return [
1903
			'artist' => \array_map(function (Artist $artist) use ($userId, $genreMap, $genreKey, $oldCountApi) {
1904
				$name = $artist->getNameString($this->l10n);
1905
				$nameParts = $this->prefixAndBaseName($name);
1906
				$albumCount = $this->albumBusinessLayer->countByAlbumArtist($artist->getId());
1907
				$songCount = $this->trackBusinessLayer->countByArtist($artist->getId());
1908
				$albums = $artist->getAlbums();
1909
				$songs = $artist->getTracks();
1910
1911
				$apiArtist = [
1912
					'id' => (string)$artist->getId(),
1913
					'name' => $name,
1914
					'prefix' => $nameParts['prefix'],
1915
					'basename' => $nameParts['basename'],
1916
					'albums' => ($albums !== null) ? $this->renderAlbums($albums) : ($oldCountApi ? $albumCount : null),
1917
					'albumcount' => $albumCount,
1918
					'songs' => ($songs !== null) ? $this->renderSongs($songs) : ($oldCountApi ? $songCount : null),
1919
					'songcount' => $songCount,
1920
					'time' => $this->trackBusinessLayer->totalDurationByArtist($artist->getId()),
1921
					'art' => $this->createCoverUrl($artist),
1922
					'has_art' => $artist->getCoverFileId() !== null,
1923
					'rating' => $artist->getRating() ?? 0,
1924
					'preciserating' => $artist->getRating() ?? 0,
1925
					'flag' => !empty($artist->getStarred()),
1926
					$genreKey => \array_map(function ($genreId) use ($genreMap) {
1927
						return [
1928
							'id' => (string)$genreId,
1929
							'text' => $genreMap[$genreId]->getNameString($this->l10n),
1930
							'count' => 1
1931
						];
1932
					}, $this->trackBusinessLayer->getGenresByArtistId($artist->getId(), $userId))
1933
				];
1934
1935
				if ($this->jsonMode) {
1936
					// Remove an unnecessary level on the JSON API
1937
					if ($albums !== null) {
1938
						$apiArtist['albums'] = $apiArtist['albums']['album'];
1939
					}
1940
					if ($songs !== null) {
1941
						$apiArtist['songs'] = $apiArtist['songs']['song'];
1942
					}
1943
				}
1944
1945
				return $apiArtist;
1946
			}, $artists)
1947
		];
1948
	}
1949
1950
	/**
1951
	 * @param Album[] $albums
1952
	 */
1953
	private function renderAlbums(array $albums) : array {
1954
		$genreKey = $this->genreKey();
1955
		$apiMajor = $this->apiMajorVersion();
1956
		// In APIv6 JSON format, there is a new property `artists` with an array value
1957
		$includeArtists = ($this->jsonMode && $apiMajor > 5);
1958
		// In APIv3-4, the property 'tracks' was used for the song count in case the inclusion of songs wasn't requested.
1959
		// APIv5+ has the property 'songcount' for this and 'tracks' may only contain objects.
1960
		$tracksMayDenoteCount = ($apiMajor < 5);
1961
1962
		return [
1963
			'album' => \array_map(function (Album $album) use ($genreKey, $includeArtists, $tracksMayDenoteCount) {
1964
				$name = $album->getNameString($this->l10n);
1965
				$nameParts = $this->prefixAndBaseName($name);
1966
				$songCount = $this->trackBusinessLayer->countByAlbum($album->getId());
1967
				$songs = $album->getTracks();
1968
1969
				$apiAlbum = [
1970
					'id' => (string)$album->getId(),
1971
					'name' => $name,
1972
					'prefix' => $nameParts['prefix'],
1973
					'basename' => $nameParts['basename'],
1974
					'artist' => $this->renderAlbumOrArtistRef(
1975
						$album->getAlbumArtistId(),
1976
						$album->getAlbumArtistNameString($this->l10n)
1977
					),
1978
					'tracks' => ($songs !== null) ? $this->renderSongs($songs, false) : ($tracksMayDenoteCount ? $songCount : null),
1979
					'songcount' => $songCount,
1980
					'diskcount' => $album->getNumberOfDisks(),
1981
					'time' => $this->trackBusinessLayer->totalDurationOfAlbum($album->getId()),
1982
					'rating' => $album->getRating() ?? 0,
1983
					'preciserating' => $album->getRating() ?? 0,
1984
					'year' => $album->yearToAPI(),
1985
					'art' => $this->createCoverUrl($album),
1986
					'has_art' => $album->getCoverFileId() !== null,
1987
					'flag' => !empty($album->getStarred()),
1988
					$genreKey => \array_map(function ($genre) {
1989
						return [
1990
							'id' => (string)$genre->getId(),
1991
							'text' => $genre->getNameString($this->l10n),
1992
							'count' => 1
1993
						];
1994
					}, $album->getGenres() ?? [])
1995
				];
1996
				if ($includeArtists) {
1997
					$apiAlbum['artists'] = [$apiAlbum['artist']];
1998
				}
1999
				if ($this->jsonMode && $songs !== null) {
2000
					// Remove an unnecessary level on the JSON API
2001
					$apiAlbum['tracks'] = $apiAlbum['tracks']['song'];
2002
				}
2003
2004
				return $apiAlbum;
2005
			}, $albums)
2006
		];
2007
	}
2008
2009
	/**
2010
	 * @param Track[] $tracks
2011
	 */
2012
	private function renderSongs(array $tracks, bool $injectAlbums=true) : array {
2013
		if ($injectAlbums) {
2014
			$userId = $this->session->getUserId();
2015
			$this->albumBusinessLayer->injectAlbumsToTracks($tracks, $userId);
2016
		}
2017
2018
		$createPlayUrl = function(Track $track) : string {
2019
			return $this->createAmpacheActionUrl('download', $track->getId());
2020
		};
2021
		$createImageUrl = function(Track $track) : string {
2022
			$album = $track->getAlbum();
2023
			return ($album !== null) ? $this->createCoverUrl($album) : '';
2024
		};
2025
		$renderRef = function(int $id, string $name) : array {
2026
			return $this->renderAlbumOrArtistRef($id, $name);
2027
		};
2028
		$genreKey = $this->genreKey();
2029
		// In APIv6 JSON format, there is a new property `artists` with an array value
2030
		$includeArtists = ($this->jsonMode && $this->apiMajorVersion() > 5);
2031
2032
		return [
2033
			'song' => Util::arrayMapMethod($tracks, 'toAmpacheApi', 
2034
				[$this->l10n, $createPlayUrl, $createImageUrl, $renderRef, $genreKey, $includeArtists])
2035
		];
2036
	}
2037
2038
	/**
2039
	 * @param Playlist[] $playlists
2040
	 */
2041
	private function renderPlaylists(array $playlists) : array {
2042
		$createImageUrl = function(Playlist $playlist) : string {
2043
			if ($playlist->getId() === self::ALL_TRACKS_PLAYLIST_ID) {
2044
				return '';
2045
			} else {
2046
				return $this->createCoverUrl($playlist);
2047
			}
2048
		};
2049
2050
		return [
2051
			'playlist' => Util::arrayMapMethod($playlists, 'toAmpacheApi', [$createImageUrl])
2052
		];
2053
	}
2054
2055
	/**
2056
	 * @param PodcastChannel[] $channels
2057
	 */
2058
	private function renderPodcastChannels(array $channels) : array {
2059
		return [
2060
			'podcast' => Util::arrayMapMethod($channels, 'toAmpacheApi')
2061
		];
2062
	}
2063
2064
	/**
2065
	 * @param PodcastEpisode[] $episodes
2066
	 */
2067
	private function renderPodcastEpisodes(array $episodes) : array {
2068
		return [
2069
			'podcast_episode' => Util::arrayMapMethod($episodes, 'toAmpacheApi')
2070
		];
2071
	}
2072
2073
	/**
2074
	 * @param RadioStation[] $stations
2075
	 */
2076
	private function renderLiveStreams(array $stations) : array {
2077
		return [
2078
			'live_stream' => Util::arrayMapMethod($stations, 'toAmpacheApi')
2079
		];
2080
	}
2081
2082
	/**
2083
	 * @param Genre[] $genres
2084
	 */
2085
	private function renderTags(array $genres) : array {
2086
		return [
2087
			'tag' => Util::arrayMapMethod($genres, 'toAmpacheApi', [$this->l10n])
2088
		];
2089
	}
2090
2091
	/**
2092
	 * @param Genre[] $genres
2093
	 */
2094
	private function renderGenres(array $genres) : array {
2095
		return [
2096
			'genre' => Util::arrayMapMethod($genres, 'toAmpacheApi', [$this->l10n])
2097
		];
2098
	}
2099
2100
	/**
2101
	 * @param Bookmark[] $bookmarks
2102
	 */
2103
	private function renderBookmarks(array $bookmarks, int $include=0) : array {
2104
		$renderEntry = null;
2105
2106
		if ($include) {
2107
			$renderEntry = function(string $type, int $id) {
2108
				$businessLayer = $this->getBusinessLayer($type);
2109
				$entity = $businessLayer->find($id, $this->session->getUserId());
2110
				return $this->renderEntities([$entity], $type)[$type][0];
2111
			};
2112
		}
2113
2114
		return [
2115
			'bookmark' => Util::arrayMapMethod($bookmarks, 'toAmpacheApi', [$renderEntry])
2116
		];
2117
	}
2118
2119
	/**
2120
	 * @param Track[] $tracks
2121
	 */
2122
	private function renderSongsIndex(array $tracks) : array {
2123
		return [
2124
			'song' => \array_map(function ($track) {
2125
				return [
2126
					'id' => (string)$track->getId(),
2127
					'title' => $track->getTitle(),
2128
					'name' => $track->getTitle(),
2129
					'artist' => $this->renderAlbumOrArtistRef($track->getArtistId(), $track->getArtistNameString($this->l10n)),
2130
					'album' => $this->renderAlbumOrArtistRef($track->getAlbumId(), $track->getAlbumNameString($this->l10n))
2131
				];
2132
			}, $tracks)
2133
		];
2134
	}
2135
2136
	/**
2137
	 * @param Album[] $albums
2138
	 */
2139
	private function renderAlbumsIndex(array $albums) : array {
2140
		return [
2141
			'album' => \array_map(function ($album) {
2142
				$name = $album->getNameString($this->l10n);
2143
				$nameParts = $this->prefixAndBaseName($name);
2144
2145
				return [
2146
					'id' => (string)$album->getId(),
2147
					'name' => $name,
2148
					'prefix' => $nameParts['prefix'],
2149
					'basename' => $nameParts['basename'],
2150
					'artist' => $this->renderAlbumOrArtistRef($album->getAlbumArtistId(), $album->getAlbumArtistNameString($this->l10n))
2151
				];
2152
			}, $albums)
2153
		];
2154
	}
2155
2156
	/**
2157
	 * @param Artist[] $artists
2158
	 */
2159
	private function renderArtistsIndex(array $artists) : array {
2160
		return [
2161
			'artist' => \array_map(function ($artist) {
2162
				$userId = $this->session->getUserId();
2163
				$albums = $this->albumBusinessLayer->findAllByArtist($artist->getId(), $userId);
2164
				$name = $artist->getNameString($this->l10n);
2165
				$nameParts = $this->prefixAndBaseName($name);
2166
2167
				return [
2168
					'id' => (string)$artist->getId(),
2169
					'name' => $name,
2170
					'prefix' => $nameParts['prefix'],
2171
					'basename' => $nameParts['basename'],
2172
					'album' => \array_map(function ($album) {
2173
						return $this->renderAlbumOrArtistRef($album->getId(), $album->getNameString($this->l10n));
2174
					}, $albums)
2175
				];
2176
			}, $artists)
2177
		];
2178
	}
2179
2180
	/**
2181
	 * @param Playlist[] $playlists
2182
	 */
2183
	private function renderPlaylistsIndex(array $playlists) : array {
2184
		return [
2185
			'playlist' => \array_map(function ($playlist) {
2186
				return [
2187
					'id' => (string)$playlist->getId(),
2188
					'name' => $playlist->getName(),
2189
					'playlisttrack' => $playlist->getTrackIdsAsArray()
2190
				];
2191
			}, $playlists)
2192
		];
2193
	}
2194
2195
	/**
2196
	 * @param PodcastChannel[] $channels
2197
	 */
2198
	private function renderPodcastChannelsIndex(array $channels) : array {
2199
		// The v4 API spec does not give any examples of this, and the v5 example is almost identical to the v4 "normal" result
2200
		return $this->renderPodcastChannels($channels);
2201
	}
2202
2203
	/**
2204
	 * @param PodcastEpisode[] $episodes
2205
	 */
2206
	private function renderPodcastEpisodesIndex(array $episodes) : array {
2207
		// The v4 API spec does not give any examples of this, and the v5 example is almost identical to the v4 "normal" result
2208
		return $this->renderPodcastEpisodes($episodes);
2209
	}
2210
2211
	/**
2212
	 * @param RadioStation[] $stations
2213
	 */
2214
	private function renderLiveStreamsIndex(array $stations) : array {
2215
		// The API spec gives no examples of this, but testing with Ampache demo server revealed that the format is identical to the "full" format
2216
		return $this->renderLiveStreams($stations);
2217
	}
2218
2219
	/**
2220
	 * @param Entity[] $entities
2221
	 */
2222
	private function renderEntityIds(array $entities, string $key = 'id') : array {
2223
		return [$key => Util::extractIds($entities)];
2224
	}
2225
2226
	/**
2227
	 * Render the way used by `action=index` when `include=0`
2228
	 * @param Entity[] $entities
2229
	 */
2230
	private function renderEntityIdIndex(array $entities, string $type) : array {
2231
		// the structure is quite different for JSON compared to XML
2232
		if ($this->jsonMode) {
2233
			return $this->renderEntityIds($entities, $type);
2234
		} else {
2235
			return [$type => \array_map(function($entity) {
2236
				return [
2237
					'id' => $entity->getId()
2238
				];
2239
			}, $entities)];
2240
		}
2241
	}
2242
2243
	/**
2244
	 * Render the way used by `action=index` when `include=1`
2245
	 * @param array $idsWithChildren Array like [int => int[]]
2246
	 */
2247
	private function renderIdsWithChildren(array $idsWithChildren, string $type, string $childType) : array {
2248
		// the structure is quite different for JSON compared to XML
2249
		if ($this->jsonMode) {
2250
			foreach ($idsWithChildren as &$children) {
2251
				$children = \array_map(function ($childId) use ($childType) {
2252
					return ['id' => $childId, 'type' => $childType];
2253
				}, $children);
2254
			}
2255
			return [$type => $idsWithChildren];
2256
		} else {
2257
			return [$type => \array_map(function($id, $childIds) use ($childType) {
2258
				return [
2259
					'id' => $id,
2260
					$childType => \array_map(function($id) {
2261
						return ['id' => $id];
2262
					}, $childIds)
2263
				];
2264
			}, \array_keys($idsWithChildren), $idsWithChildren)];
2265
		}
2266
	}
2267
2268
	/**
2269
	 * Array is considered to be "indexed" if its first element has numerical key.
2270
	 * Empty array is considered to be "indexed".
2271
	 */
2272
	private static function arrayIsIndexed(array $array) : bool {
2273
		\reset($array);
2274
		return empty($array) || \is_int(\key($array));
2275
	}
2276
2277
	/**
2278
	 * The JSON API has some asymmetries with the XML API. This function makes the needed
2279
	 * translations for the result content before it is converted into JSON.
2280
	 */
2281
	private function prepareResultForJsonApi(array $content) : array {
2282
		$apiVer = $this->apiMajorVersion();
2283
2284
		// Special handling is needed for responses returning an array of library entities,
2285
		// depending on the API version. In these cases, the outermost array is of associative
2286
		// type with a single value which is a non-associative array.
2287
		if (\count($content) === 1 && !self::arrayIsIndexed($content)
2288
				&& \is_array(\current($content)) && self::arrayIsIndexed(\current($content))) {
2289
			// In API versions < 5, the root node is an anonymous array. Unwrap the outermost array.
2290
			if ($apiVer < 5) {
2291
				$content = \array_pop($content);
2292
			}
2293
			// In later versions, the root object has a named array for plural actions (like "songs", "artists").
2294
			// For singular actions (like "song", "artist"), the root object contains directly the entity properties.
2295
			else {
2296
				$action = $this->request->getParam('action');
2297
				$plural = (\substr($action, -1) === 's' || \in_array($action, ['get_similar', 'advanced_search', 'search', 'list', 'index']));
2298
2299
				// In APIv5, the action "album" is an exception, it is formatted as if it was a plural action.
2300
				// This outlier has been fixed in APIv6.
2301
				$api5albumOddity = ($apiVer === 5 && $action === 'album');
2302
2303
				// The actions "user_preference" and "system_preference" are another kind of outliers in APIv5,
2304
				// their responses are anonymous 1-item arrays. This got fixed in the APIv6.0.1
2305
				$api5preferenceOddity = ($apiVer === 5 && Util::endsWith($action, 'preference'));
2306
2307
				if ($api5preferenceOddity) {
2308
					$content = \array_pop($content);
2309
				} elseif (!($plural  || $api5albumOddity)) {
2310
					$content = \array_pop($content);
2311
					$content = \array_pop($content);
2312
				}
2313
			}
2314
		}
2315
2316
		// In API versions < 6, all boolean valued properties should be converted to 0/1.
2317
		if ($apiVer < 6) {
2318
			Util::intCastArrayValues($content, 'is_bool');
2319
		}
2320
2321
		// The key 'text' has a special meaning on XML responses, as it makes the corresponding value
2322
		// to be treated as text content of the parent element. In the JSON API, these are mostly
2323
		// substituted with property 'name', but error responses use the property 'message', instead.
2324
		if (\array_key_exists('error', $content)) {
2325
			$content = Util::convertArrayKeys($content, ['text' => 'message']);
2326
		} else {
2327
			$content = Util::convertArrayKeys($content, ['text' => 'name']);
2328
		}
2329
		return $content;
2330
	}
2331
2332
	/**
2333
	 * The XML API has some asymmetries with the JSON API. This function makes the needed
2334
	 * translations for the result content before it is converted into XML.
2335
	 */
2336
	private function prepareResultForXmlApi(array $content) : array {
2337
		\reset($content);
2338
		$firstKey = \key($content);
2339
2340
		// all 'entity list' kind of responses shall have the (deprecated) total_count element
2341
		if (\in_array($firstKey, ['song', 'album', 'artist', 'album_artist', 'song_artist',
2342
				'playlist', 'tag', 'genre', 'podcast', 'podcast_episode', 'live_stream'])) {
2343
			$content = ['total_count' => \count($content[$firstKey])] + $content;
2344
		}
2345
2346
		// for some bizarre reason, the 'id' arrays have 'index' attributes in the XML format
2347
		if ($firstKey == 'id') {
2348
			$content['id'] = \array_map(function ($id, $index) {
2349
				return ['index' => $index, 'text' => $id];
2350
			}, $content['id'], \array_keys($content['id']));
2351
		}
2352
2353
		return ['root' => $content];
2354
	}
2355
2356
	private function genreKey() : string {
2357
		return ($this->apiMajorVersion() > 4) ? 'genre' : 'tag';
2358
	}
2359
2360
	private function apiMajorVersion() : int {
2361
		// During the handshake, we don't yet have a session but the requested version may be in the request args
2362
		$verString = ($this->session !== null) 
2363
			? $this->session->getApiVersion()
2364
			: $this->request->getParam('version');
2365
		
2366
		if (\is_string($verString) && \strlen($verString)) {
2367
			$ver = (int)$verString[0];
2368
		} else {
2369
			// Default version is 6 unless otherwise defined in config.php
2370
			$ver = (int)$this->config->getSystemValue('music.ampache_api_default_ver', 6);
2371
		}
2372
2373
		// For now, we have three supported major versions. Major version 3 can be sufficiently supported
2374
		// with our "version 4" implementation.
2375
		return (int)Util::limit($ver, 4, 6);
2376
	}
2377
2378
	private function apiVersionString() : string {
2379
		switch ($this->apiMajorVersion()) {
2380
			case 4:		return self::API4_VERSION;
2381
			case 5:		return self::API5_VERSION;
2382
			case 6:		return self::API6_VERSION;
2383
			default:	throw new AmpacheException('Unexpected api major version', 500);
2384
		}
2385
	}
2386
2387
	private function mapApiV4ErrorToV5(int $code) : int {
2388
		switch ($code) {
2389
			case 400:	return 4710;	// bad request
2390
			case 401:	return 4701;	// invalid handshake
2391
			case 403:	return 4703;	// access denied
2392
			case 404:	return 4704;	// not found
2393
			case 405:	return 4705;	// missing
2394
			case 412:	return 4742;	// failed access check
2395
			case 501:	return 4700;	// access control not enabled
2396
			default:	return 5000;	// unexpected (not part of the API spec)
2397
		}
2398
	}
2399
}
2400