AmpacheController::playlist_generate()   B
last analyzed

Complexity

Conditions 8
Paths 40

Size

Total Lines 36
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Importance

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

How to fix   Many Parameters   

Many Parameters

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

There are several approaches to avoid long parameter lists:

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