AmpacheController::__construct()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 56
Code Lines 28

Duplication

Lines 0
Ratio 0 %

Importance

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

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