Passed
Push — master ( c25ba4...014884 )
by Pauli
03:57
created

AmpacheController::artists()   A

Complexity

Conditions 5
Paths 12

Size

Total Lines 23
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

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