AmpacheController::index()   B
last analyzed

Complexity

Conditions 9
Paths 20

Size

Total Lines 37
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 9
eloc 25
nc 20
nop 8
dl 0
loc 37
rs 8.0555
c 1
b 0
f 0

How to fix   Many Parameters   

Many Parameters

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

There are several approaches to avoid long parameter lists:

1
<?php declare(strict_types=1);
2
3
/**
4
 * ownCloud - Music app
5
 *
6
 * This file is licensed under the Affero General Public License version 3 or
7
 * later. See the COPYING file.
8
 *
9
 * @author Morris Jobke <[email protected]>
10
 * @author Pauli Järvinen <[email protected]>
11
 * @copyright Morris Jobke 2013, 2014
12
 * @copyright Pauli Järvinen 2017 - 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