Passed
Pull Request — master (#1172)
by Pauli
10:14 queued 07:20
created

AmpacheController::playlist_edit()   B

Complexity

Conditions 7
Paths 12

Size

Total Lines 35
Code Lines 24

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 24
c 1
b 0
f 0
dl 0
loc 35
rs 8.6026
cc 7
nc 12
nop 4
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