AmpacheController::podcast_delete()   A
last analyzed

Complexity

Conditions 3
Paths 3

Size

Total Lines 11
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

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