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