php$0 ➔ prepareResultForJsonApi()   C
last analyzed

Complexity

Conditions 16

Size

Total Lines 52

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 16
dl 0
loc 52
rs 5.5666
c 0
b 0
f 0

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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