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

AmpacheController.php$0 ➔ renderAlbumsIndex()   A

Complexity

Conditions 1

Size

Total Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

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