Passed
Push — master ( ce9a66...c3a2b5 )
by Pauli
02:52
created

AmpacheController::userId()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 5
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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