AmpacheController::__construct()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 58
Code Lines 28

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 28
c 1
b 0
f 0
nc 1
nop 26
dl 0
loc 58
rs 9.472

How to fix   Long Method    Many Parameters   

Long Method

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

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

Commonly applied refactorings include:

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