Passed
Push — master ( a5b6e2...a309ae )
by Pauli
15:49
created

AmpacheController::playlist_generate()   B

Complexity

Conditions 8
Paths 40

Size

Total Lines 42
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 8
eloc 26
c 0
b 0
f 0
nc 40
nop 8
dl 0
loc 42
rs 8.4444

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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