Passed
Push — master ( ce9a66...c3a2b5 )
by Pauli
02:52
created

AmpacheController::playlist_generate()   B

Complexity

Conditions 8
Paths 40

Size

Total Lines 36
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Importance

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

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