AmpacheController::artist_albums()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

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