Passed
Push — master ( 022c2a...9c43c9 )
by Pauli
02:58
created

AmpacheController::playlist_generate()   B

Complexity

Conditions 8
Paths 40

Size

Total Lines 42
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 8
eloc 26
c 0
b 0
f 0
nc 40
nop 8
dl 0
loc 42
rs 8.4444

How to fix   Many Parameters   

Many Parameters

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

There are several approaches to avoid long parameter lists:

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