Passed
Pull Request — master (#1141)
by Pauli
04:07
created

AmpacheController::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 44
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 1
eloc 21
c 2
b 0
f 0
nc 1
nop 22
dl 0
loc 44
rs 9.584

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 - 2024
13
 */
14
15
namespace OCA\Music\Controller;
16
17
use OCP\AppFramework\Controller;
18
use OCP\AppFramework\Http;
19
use OCP\AppFramework\Http\JSONResponse;
20
use OCP\AppFramework\Http\RedirectResponse;
21
use OCP\AppFramework\Http\Response;
22
use OCP\IConfig;
23
use OCP\IL10N;
24
use OCP\IRequest;
25
use OCP\IURLGenerator;
26
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
			$entities = $businessLayer->findAllAdvanced($operator, $rules, $userId, SortBy::Name, $random ? $this->random : null, $limit, $offset);
1221
		} catch (BusinessLayerException $e) {
1222
			throw new AmpacheException($e->getMessage(), 400);
1223
		}
1224
		
1225
		return $this->renderEntities($entities, $type);
1226
	}
1227
1228
	/**
1229
	 * @AmpacheAPI
1230
	 */
1231
	protected function flag(string $type, int $id, bool $flag) : array {
1232
		if (!\in_array($type, ['song', 'album', 'artist', 'podcast', 'podcast_episode', 'playlist'])) {
1233
			throw new AmpacheException("Unsupported type $type", 400);
1234
		}
1235
1236
		$userId = $this->session->getUserId();
1237
		$businessLayer = $this->getBusinessLayer($type);
1238
		if ($flag) {
1239
			$modifiedCount = $businessLayer->setStarred([$id], $userId);
1240
			$message = "flag ADDED to $type $id";
1241
		} else {
1242
			$modifiedCount = $businessLayer->unsetStarred([$id], $userId);
1243
			$message = "flag REMOVED from $type $id";
1244
		}
1245
1246
		if ($modifiedCount > 0) {
1247
			return ['success' => $message];
1248
		} else {
1249
			throw new AmpacheException("The $type $id was not found", 404);
1250
		}
1251
	}
1252
1253
	/**
1254
	 * @AmpacheAPI
1255
	 */
1256
	protected function rate(string $type, int $id, int $rating) : array {
1257
		$rating = Util::limit($rating, 0, 5);
1258
		$userId = $this->session->getUserId();
1259
		$businessLayer = $this->getBusinessLayer($type);
1260
		$entity = $businessLayer->find($id, $userId);
1261
		if (\property_exists($entity, 'rating')) {
1262
			// Scrutinizer and PHPStan don't understand the connection between the property 'rating' and the method 'setRating'
1263
			$entity->/** @scrutinizer ignore-call */setRating($rating); // @phpstan-ignore-line
1264
			$businessLayer->update($entity);
1265
		} else {
1266
			throw new AmpacheException("Unsupported type $type", 400);
1267
		}
1268
1269
		return ['success' => "rating set to $rating for $type $id"];
1270
	}
1271
1272
	/**
1273
	 * @AmpacheAPI
1274
	 */
1275
	protected function record_play(int $id, ?int $date) : array {
1276
		$timeOfPlay = ($date === null) ? null : new \DateTime('@' . $date);
1277
		$this->trackBusinessLayer->recordTrackPlayed($id, $this->session->getUserId(), $timeOfPlay);
1278
		return ['success' => 'play recorded'];
1279
	}
1280
1281
	/**
1282
	 * @AmpacheAPI
1283
	 */
1284
	protected function user_preferences() : array {
1285
		return ['user_preference' => AmpachePreferences::getAll()];
1286
	}
1287
1288
	/**
1289
	 * @AmpacheAPI
1290
	 */
1291
	protected function user_preference(string $filter) : array {
1292
		$pref = AmpachePreferences::get($filter);
1293
		if ($pref === null) {
1294
			throw new AmpacheException("Not Found: $filter", 400);
1295
		} else {
1296
			return ['user_preference' => [$pref]];
1297
		}
1298
	}
1299
1300
	/**
1301
	 * @AmpacheAPI
1302
	 */
1303
	protected function download(int $id, string $type='song') : Response {
1304
		// request param `format` is ignored
1305
		$userId = $this->session->getUserId();
1306
1307
		if ($type === 'song') {
1308
			try {
1309
				$track = $this->trackBusinessLayer->find($id, $userId);
1310
			} catch (BusinessLayerException $e) {
1311
				return new ErrorResponse(Http::STATUS_NOT_FOUND, $e->getMessage());
1312
			}
1313
1314
			$file = $this->librarySettings->getFolder($userId)->getById($track->getFileId())[0] ?? null;
1315
1316
			if ($file instanceof \OCP\Files\File) {
1317
				return new FileStreamResponse($file);
1318
			} else {
1319
				return new ErrorResponse(Http::STATUS_NOT_FOUND);
1320
			}
1321
		} elseif ($type === 'podcast' || $type === 'podcast_episode') { // there's a difference between APIv4 and APIv5
1322
			$episode = $this->podcastEpisodeBusinessLayer->find($id, $userId);
1323
			return new RedirectResponse($episode->getStreamUrl());
1324
		} elseif ($type === 'playlist') {
1325
			$songIds = ($id === self::ALL_TRACKS_PLAYLIST_ID)
1326
				? $this->trackBusinessLayer->findAllIds($userId)
1327
				: $this->playlistBusinessLayer->find($id, $userId)->getTrackIdsAsArray();
1328
			$randomId = Random::pickItem($songIds);
1329
			if ($randomId === null) {
1330
				throw new AmpacheException("The playlist $id is empty", 404);
1331
			} else {
1332
				return $this->download((int)$randomId);
1333
			}
1334
		} else {
1335
			throw new AmpacheException("Unsupported type '$type'", 400);
1336
		}
1337
	}
1338
1339
	/**
1340
	 * @AmpacheAPI
1341
	 */
1342
	protected function stream(int $id, ?int $offset, string $type='song') : Response {
1343
		// request params `bitrate`, `format`, and `length` are ignored
1344
1345
		// This is just a dummy implementation. We don't support transcoding or streaming
1346
		// from a time offset.
1347
		// All the other unsupported arguments are just ignored, but a request with an offset
1348
		// is responded with an error. This is becuase the client would probably work in an
1349
		// unexpected way if it thinks it's streaming from offset but actually it is streaming
1350
		// from the beginning of the file. Returning an error gives the client a chance to fallback
1351
		// to other methods of seeking.
1352
		if ($offset !== null) {
1353
			throw new AmpacheException('Streaming with time offset is not supported', 400);
1354
		}
1355
1356
		return $this->download($id, $type);
1357
	}
1358
1359
	/**
1360
	 * @AmpacheAPI
1361
	 */
1362
	protected function get_art(string $type, int $id) : Response {
1363
		if (!\in_array($type, ['song', 'album', 'artist', 'podcast', 'playlist'])) {
1364
			throw new AmpacheException("Unsupported type $type", 400);
1365
		}
1366
1367
		if ($type === 'song') {
1368
			// map song to its parent album
1369
			$id = $this->trackBusinessLayer->find($id, $this->session->getUserId())->getAlbumId();
1370
			$type = 'album';
1371
		}
1372
1373
		return $this->getCover($id, $this->getBusinessLayer($type));
1374
	}
1375
1376
	/********************
1377
	 * Helper functions *
1378
	 ********************/
1379
1380
	private function getBusinessLayer(string $type) : BusinessLayer {
1381
		switch ($type) {
1382
			case 'song':			return $this->trackBusinessLayer;
1383
			case 'album':			return $this->albumBusinessLayer;
1384
			case 'artist':			return $this->artistBusinessLayer;
1385
			case 'playlist':		return $this->playlistBusinessLayer;
1386
			case 'podcast':			return $this->podcastChannelBusinessLayer;
1387
			case 'podcast_episode':	return $this->podcastEpisodeBusinessLayer;
1388
			case 'live_stream':		return $this->radioStationBusinessLayer;
1389
			case 'tag':				return $this->genreBusinessLayer;
1390
			case 'genre':			return $this->genreBusinessLayer;
1391
			case 'bookmark':		return $this->bookmarkBusinessLayer;
1392
			default:				throw new AmpacheException("Unsupported type $type", 400);
1393
		}
1394
	}
1395
1396
	private function renderEntities(array $entities, string $type) : array {
1397
		switch ($type) {
1398
			case 'song':			return $this->renderSongs($entities);
1399
			case 'album':			return $this->renderAlbums($entities);
1400
			case 'artist':			return $this->renderArtists($entities);
1401
			case 'playlist':		return $this->renderPlaylists($entities);
1402
			case 'podcast':			return $this->renderPodcastChannels($entities);
1403
			case 'podcast_episode':	return $this->renderPodcastEpisodes($entities);
1404
			case 'live_stream':		return $this->renderLiveStreams($entities);
1405
			case 'tag':				return $this->renderTags($entities);
1406
			case 'genre':			return $this->renderGenres($entities);
1407
			case 'bookmark':		return $this->renderBookmarks($entities);
1408
			default:				throw new AmpacheException("Unsupported type $type", 400);
1409
		}
1410
	}
1411
1412
	private function renderEntitiesIndex($entities, $type) : array {
1413
		switch ($type) {
1414
			case 'song':			return $this->renderSongsIndex($entities);
1415
			case 'album':			return $this->renderAlbumsIndex($entities);
1416
			case 'artist':			return $this->renderArtistsIndex($entities);
1417
			case 'playlist':		return $this->renderPlaylistsIndex($entities);
1418
			case 'podcast':			return $this->renderPodcastChannelsIndex($entities);
1419
			case 'podcast_episode':	return $this->renderPodcastEpisodesIndex($entities);
1420
			case 'live_stream':		return $this->renderLiveStreamsIndex($entities);
1421
			case 'tag':				return $this->renderTags($entities); // not part of the API spec
1422
			case 'genre':			return $this->renderGenres($entities); // not part of the API spec
1423
			case 'bookmark':		return $this->renderBookmarks($entities); // not part of the API spec
1424
			default:				throw new AmpacheException("Unsupported type $type", 400);
1425
		}
1426
	}
1427
1428
	private static function mapBookmarkType(string $ampacheType) : int {
1429
		switch ($ampacheType) {
1430
			case 'song':			return Bookmark::TYPE_TRACK;
1431
			case 'podcast_episode':	return Bookmark::TYPE_PODCAST_EPISODE;
1432
			default:				throw new AmpacheException("Unsupported type $ampacheType", 400);
1433
		}
1434
	}
1435
1436
	private static function advSearchResolveRuleAlias(string $rule) : string {
1437
		switch ($rule) {
1438
			case 'name':					return 'title';
1439
			case 'song_title':				return 'song';
1440
			case 'album_title':				return 'album';
1441
			case 'artist_title':			return 'artist';
1442
			case 'podcast_title':			return 'podcast';
1443
			case 'podcast_episode_title':	return 'podcast_episode';
1444
			case 'album_artist_title':		return 'album_artist';
1445
			case 'song_artist_title':		return 'song_artist';
1446
			case 'tag':						return 'genre';
1447
			case 'song_tag':				return 'song_genre';
1448
			case 'album_tag':				return 'album_genre';
1449
			case 'artist_tag':				return 'artist_genre';
1450
			case 'no_tag':					return 'no_genre';
1451
			default:						return $rule;
1452
		}
1453
	}
1454
1455
	private static function advSearchGetRuleParams(array $urlParams) : array {
1456
		$rules = [];
1457
1458
		// read and organize the rule parameters
1459
		foreach ($urlParams as $key => $value) {
1460
			$parts = \explode('_', $key, 3);
1461
			if ($parts[0] == 'rule' && \count($parts) > 1) {
1462
				if (\count($parts) == 2) {
1463
					$rules[$parts[1]]['rule'] = $value;
1464
				} elseif ($parts[2] == 'operator') {
1465
					$rules[$parts[1]]['operator'] = (int)$value;
1466
				} elseif ($parts[2] == 'input') {
1467
					$rules[$parts[1]]['input'] = $value;
1468
				}
1469
			}
1470
		}
1471
1472
		// validate the rule parameters
1473
		if (\count($rules) === 0) {
1474
			throw new AmpacheException('At least one rule must be given', 400);
1475
		}
1476
		foreach ($rules as $rule) {
1477
			if (\count($rule) != 3) {
1478
				throw new AmpacheException('All rules must be given as triplet "rule_N", "rule_N_operator", "rule_N_input"', 400);
1479
			}
1480
		}
1481
1482
		return $rules;
1483
	}
1484
1485
	// NOTE: alias rule names should be resolved to their base form before calling this
1486
	private static function advSearchInterpretOperator(int $rule_operator, string $rule) : string {
1487
		// Operator mapping is different for text, numeric, date, boolean, and day rules
1488
1489
		$textRules = [
1490
			'anywhere', 'title', 'song', 'album', 'artist', 'podcast', 'podcast_episode', 'album_artist', 'song_artist',
1491
			'favorite', 'favorite_album', 'favorite_artist', 'genre', 'song_genre', 'album_genre', 'artist_genre',
1492
			'playlist_name', 'type', 'file', 'mbid', 'mbid_album', 'mbid_artist', 'mbid_song'
1493
		];
1494
		// text but no support planned: 'composer', 'summary', 'placeformed', 'release_type', 'release_status', 'barcode',
1495
		// 'catalog_number', 'label', 'comment', 'lyrics', 'username', 'category'
1496
1497
		$numericRules = [
1498
			'track', 'year', 'original_year', 'myrating', 'rating', 'songrating', 'albumrating', 'artistrating',
1499
			'played_times', 'album_count', 'song_count', 'time', 'bitrate'
1500
		];
1501
		// numeric but no support planned: 'yearformed', 'skipped_times', 'play_skip_ratio', 'image_height', 'image_width'
1502
1503
		$numericLimitRules = ['recent_played', 'recent_added', 'recent_updated'];
1504
1505
		$dateOrDayRules = ['added', 'updated', 'pubdate', 'last_play'];
1506
1507
		$booleanRules = [
1508
			'played', 'myplayed', 'myplayedalbum', 'myplayedartist', 'has_image', 'no_genre',
1509
			'my_flagged', 'my_flagged_album', 'my_flagged_artist'
1510
		];
1511
		// boolean but no support planned: 'smartplaylist', 'possible_duplicate', 'possible_duplicate_album'
1512
1513
		$booleanNumericRules = ['playlist'];
1514
		// boolean numeric but no support planned: 'license', 'state', 'catalog'
1515
1516
		if (\in_array($rule, $textRules)) {
1517
			switch ($rule_operator) {
1518
				case 0: return 'contain';		// contains
1519
				case 1: return 'notcontain';	// does not contain;
1520
				case 2: return 'start';			// starts with
1521
				case 3: return 'end';			// ends with;
1522
				case 4: return 'is';			// is
1523
				case 5: return 'isnot';			// is not
1524
				case 6: return 'sounds';		// sounds like
1525
				case 7: return 'notsounds';		// does not sound like
1526
				case 8: return 'regexp';		// matches regex
1527
				case 9: return 'notregexp';		// does not match regex
1528
				default: throw new AmpacheException("Search operator '$rule_operator' not supported for 'text' type rules", 400);
1529
			}
1530
		} elseif (\in_array($rule, $numericRules)) {
1531
			switch ($rule_operator) {
1532
				case 0: return '>=';
1533
				case 1: return '<=';
1534
				case 2: return '=';
1535
				case 3: return '!=';
1536
				case 4: return '>';
1537
				case 5: return '<';
1538
				default: throw new AmpacheException("Search operator '$rule_operator' not supported for 'numeric' type rules", 400);
1539
			}
1540
		} elseif (\in_array($rule, $numericLimitRules)) {
1541
			return 'limit';
1542
		} elseif (\in_array($rule, $dateOrDayRules)) {
1543
			switch ($rule_operator) {
1544
				case 0: return 'before';
1545
				case 1: return 'after';
1546
				default: throw new AmpacheException("Search operator '$rule_operator' not supported for 'date' or 'day' type rules", 400);
1547
			}
1548
		} elseif (\in_array($rule, $booleanRules)) {
1549
			switch ($rule_operator) {
1550
				case 0: return 'true';
1551
				case 1: return 'false';
1552
				default: throw new AmpacheException("Search operator '$rule_operator' not supported for 'boolean' type rules", 400);
1553
			}
1554
		} elseif (\in_array($rule, $booleanNumericRules)) {
1555
			switch ($rule_operator) {
1556
				case 0: return 'equal';
1557
				case 1: return 'ne';
1558
				default: throw new AmpacheException("Search operator '$rule_operator' not supported for 'boolean numeric' type rules", 400);
1559
			}
1560
		} else {
1561
			throw new AmpacheException("Search rule '$rule' not supported", 400);
1562
		}
1563
	}
1564
1565
	private static function advSearchConvertInput(string $input, string $rule) {
1566
		switch ($rule) {
1567
			case 'last_play':
1568
				// days diff to ISO date
1569
				$date = new \DateTime("$input days ago");
1570
				return $date->format(BaseMapper::SQL_DATE_FORMAT);
1571
			case 'time':
1572
				// minutes to seconds
1573
				return (string)(int)((float)$input * 60);
1574
			default:
1575
				return $input;
1576
		}
1577
	}
1578
1579
	private function getCover(int $entityId, BusinessLayer $businessLayer) : Response {
1580
		$userId = $this->session->getUserId();
1581
		$userFolder = $this->librarySettings->getFolder($userId);
1582
1583
		try {
1584
			$entity = $businessLayer->find($entityId, $userId);
1585
			$coverData = $this->coverHelper->getCover($entity, $userId, $userFolder);
1586
			if ($coverData !== null) {
1587
				return new FileResponse($coverData);
1588
			}
1589
		} catch (BusinessLayerException $e) {
1590
			return new ErrorResponse(Http::STATUS_NOT_FOUND, 'entity not found');
1591
		}
1592
1593
		return new ErrorResponse(Http::STATUS_NOT_FOUND, 'entity has no cover');
1594
	}
1595
1596
	private static function parseTimeParameters(?string $add=null, ?string $update=null) : array {
1597
		// It's not documented, but Ampache supports also specifying date range on `add` and `update` parameters
1598
		// by using '/' as separator. If there is no such separator, then the value is used as a lower limit.
1599
		$add = Util::explode('/', $add);
1600
		$update = Util::explode('/', $update);
1601
		$addMin = $add[0] ?? null;
1602
		$addMax = $add[1] ?? null;
1603
		$updateMin = $update[0] ?? null;
1604
		$updateMax = $update[1] ?? null;
1605
1606
		return [$addMin, $addMax, $updateMin, $updateMax];
1607
	}
1608
1609
	private function findEntities(
1610
			BusinessLayer $businessLayer, ?string $filter, bool $exact, ?int $limit=null, ?int $offset=null, ?string $add=null, ?string $update=null) : array {
1611
1612
		$userId = $this->session->getUserId();
1613
1614
		list($addMin, $addMax, $updateMin, $updateMax) = self::parseTimeParameters($add, $update);
1615
1616
		if ($filter) {
1617
			$matchMode = $exact ? MatchMode::Exact : MatchMode::Substring;
1618
			return $businessLayer->findAllByName($filter, $userId, $matchMode, $limit, $offset, $addMin, $addMax, $updateMin, $updateMax);
1619
		} else {
1620
			return $businessLayer->findAll($userId, SortBy::Name, $limit, $offset, $addMin, $addMax, $updateMin, $updateMax);
1621
		}
1622
	}
1623
1624
	/**
1625
	 * @param PodcastChannel[] &$channels
1626
	 */
1627
	private function injectEpisodesToChannels(array &$channels) : void {
1628
		$userId = $this->session->getUserId();
1629
		$allChannelsIncluded = (\count($channels) === $this->podcastChannelBusinessLayer->count($userId));
1630
		$this->podcastService->injectEpisodes($channels, $userId, $allChannelsIncluded);
1631
	}
1632
1633
	private function createAmpacheActionUrl(string $action, int $id, ?string $type=null) : string {
1634
		$api = $this->jsonMode ? 'music.ampache.jsonApi' : 'music.ampache.xmlApi';
1635
		$auth = $this->session->getToken();
1636
		return $this->urlGenerator->linkToRouteAbsolute($api)
1637
				. "?action=$action&id=$id&auth=$auth"
1638
				. (!empty($type) ? "&type=$type" : '');
1639
	}
1640
1641
	private function createCoverUrl(Entity $entity) : string {
1642
		if ($entity instanceof Album) {
1643
			$type = 'album';
1644
		} elseif ($entity instanceof Artist) {
1645
			$type = 'artist';
1646
		} elseif ($entity instanceof Playlist) {
1647
			$type = 'playlist';
1648
		} else {
1649
			throw new AmpacheException('unexpected entity type for cover image', 500);
1650
		}
1651
1652
		// Scrutinizer doesn't understand that the if-else above guarantees that getCoverFileId() may be called only on Album or Artist
1653
		if ($type === 'playlist' || $entity->/** @scrutinizer ignore-call */getCoverFileId()) {
1654
			$id = $entity->getId();
1655
			$token = $this->imageService->getToken($type, $id, $this->session->getAmpacheUserId());
1656
			return $this->urlGenerator->linkToRouteAbsolute('music.ampacheImage.image') . "?object_type=$type&object_id=$id&token=$token";
1657
		} else {
1658
			return '';
1659
		}
1660
	}
1661
1662
	private static function indexIsWithinOffsetAndLimit(int $index, ?int $offset, ?int $limit) : bool {
1663
		$offset = \intval($offset); // missing offset is interpreted as 0-offset
1664
		return ($limit === null) || ($index >= $offset && $index < $offset + $limit);
1665
	}
1666
1667
	private function prefixAndBaseName(?string $name) : array {
1668
		$parts = ['prefix' => null, 'basename' => $name];
1669
1670
		if ($name !== null) {
1671
			foreach ($this->namePrefixes as $prefix) {
1672
				if (Util::startsWith($name, $prefix . ' ', /*ignoreCase=*/true)) {
1673
					$parts['prefix'] = $prefix;
1674
					$parts['basename'] = \substr($name, \strlen($prefix) + 1);
1675
					break;
1676
				}
1677
			}
1678
		}
1679
1680
		return $parts;
1681
	}
1682
1683
	private function renderAlbumOrArtistRef(int $id, string $name) : array {
1684
		if ($this->apiMajorVersion() > 5) {
1685
			return [
1686
				'id' => (string)$id,
1687
				'name' => $name,
1688
			] + $this->prefixAndBaseName($name);
1689
		} else {
1690
			return [
1691
				'id' => (string)$id,
1692
				'text' => $name
1693
			];
1694
		}
1695
	}
1696
1697
	/**
1698
	 * @param Artist[] $artists
1699
	 */
1700
	private function renderArtists(array $artists) : array {
1701
		$userId = $this->session->getUserId();
1702
		$genreMap = Util::createIdLookupTable($this->genreBusinessLayer->findAll($userId));
1703
		$genreKey = $this->genreKey();
1704
		// In APIv3-4, the properties 'albums' and 'songs' were used for the album/song count in case the inclusion of the relevan
1705
		// child objects wasn't requested. APIv5+ has the dedoicated properties 'albumcount' and 'songcount' for this purpose.
1706
		$oldCountApi = ($this->apiMajorVersion() < 5);
1707
1708
		return [
1709
			'artist' => \array_map(function (Artist $artist) use ($userId, $genreMap, $genreKey, $oldCountApi) {
1710
				$name = $artist->getNameString($this->l10n);
1711
				$nameParts = $this->prefixAndBaseName($name);
1712
				$albumCount = $this->albumBusinessLayer->countByAlbumArtist($artist->getId());
1713
				$songCount = $this->trackBusinessLayer->countByArtist($artist->getId());
1714
				$albums = $artist->getAlbums();
1715
				$songs = $artist->getTracks();
1716
1717
				$apiArtist = [
1718
					'id' => (string)$artist->getId(),
1719
					'name' => $name,
1720
					'prefix' => $nameParts['prefix'],
1721
					'basename' => $nameParts['basename'],
1722
					'albums' => ($albums !== null) ? $this->renderAlbums($albums) : ($oldCountApi ? $albumCount : null),
1723
					'albumcount' => $albumCount,
1724
					'songs' => ($songs !== null) ? $this->renderSongs($songs) : ($oldCountApi ? $songCount : null),
1725
					'songcount' => $songCount,
1726
					'time' => $this->trackBusinessLayer->totalDurationByArtist($artist->getId()),
1727
					'art' => $this->createCoverUrl($artist),
1728
					'rating' => $artist->getRating() ?? 0,
1729
					'preciserating' => $artist->getRating() ?? 0,
1730
					'flag' => !empty($artist->getStarred()),
1731
					$genreKey => \array_map(function ($genreId) use ($genreMap) {
1732
						return [
1733
							'id' => (string)$genreId,
1734
							'text' => $genreMap[$genreId]->getNameString($this->l10n),
1735
							'count' => 1
1736
						];
1737
					}, $this->trackBusinessLayer->getGenresByArtistId($artist->getId(), $userId))
1738
				];
1739
1740
				if ($this->jsonMode) {
1741
					// Remove an unnecessary level on the JSON API
1742
					if ($albums !== null) {
1743
						$apiArtist['albums'] = $apiArtist['albums']['album'];
1744
					}
1745
					if ($songs !== null) {
1746
						$apiArtist['songs'] = $apiArtist['songs']['song'];
1747
					}
1748
				}
1749
1750
				return $apiArtist;
1751
			}, $artists)
1752
		];
1753
	}
1754
1755
	/**
1756
	 * @param Album[] $albums
1757
	 */
1758
	private function renderAlbums(array $albums) : array {
1759
		$genreKey = $this->genreKey();
1760
		$apiMajor = $this->apiMajorVersion();
1761
		// In APIv6 JSON format, there is a new property `artists` with an array value
1762
		$includeArtists = ($this->jsonMode && $apiMajor > 5);
1763
		// In APIv3-4, the property 'tracks' was used for the song count in case the inclusion of songs wasn't requested.
1764
		// APIv5+ has the property 'songcount' for this and 'tracks' may only contain objects.
1765
		$tracksMayDenoteCount = ($apiMajor < 5);
1766
1767
		return [
1768
			'album' => \array_map(function (Album $album) use ($genreKey, $includeArtists, $tracksMayDenoteCount) {
1769
				$name = $album->getNameString($this->l10n);
1770
				$nameParts = $this->prefixAndBaseName($name);
1771
				$songCount = $this->trackBusinessLayer->countByAlbum($album->getId());
1772
				$songs = $album->getTracks();
1773
1774
				$apiAlbum = [
1775
					'id' => (string)$album->getId(),
1776
					'name' => $name,
1777
					'prefix' => $nameParts['prefix'],
1778
					'basename' => $nameParts['basename'],
1779
					'artist' => $this->renderAlbumOrArtistRef(
1780
						$album->getAlbumArtistId(),
1781
						$album->getAlbumArtistNameString($this->l10n)
1782
					),
1783
					'tracks' => ($songs !== null) ? $this->renderSongs($songs, false) : ($tracksMayDenoteCount ? $songCount : null),
1784
					'songcount' => $songCount,
1785
					'diskcount' => $album->getNumberOfDisks(),
1786
					'time' => $this->trackBusinessLayer->totalDurationOfAlbum($album->getId()),
1787
					'rating' => $album->getRating() ?? 0,
1788
					'preciserating' => $album->getRating() ?? 0,
1789
					'year' => $album->yearToAPI(),
1790
					'art' => $this->createCoverUrl($album),
1791
					'flag' => !empty($album->getStarred()),
1792
					$genreKey => \array_map(function ($genre) {
1793
						return [
1794
							'id' => (string)$genre->getId(),
1795
							'text' => $genre->getNameString($this->l10n),
1796
							'count' => 1
1797
						];
1798
					}, $album->getGenres() ?? [])
1799
				];
1800
				if ($includeArtists) {
1801
					$apiAlbum['artists'] = [$apiAlbum['artist']];
1802
				}
1803
				if ($this->jsonMode && $songs !== null) {
1804
					// Remove an unnecessary level on the JSON API
1805
					$apiAlbum['tracks'] = $apiAlbum['tracks']['song'];
1806
				}
1807
1808
				return $apiAlbum;
1809
			}, $albums)
1810
		];
1811
	}
1812
1813
	/**
1814
	 * @param Track[] $tracks
1815
	 */
1816
	private function renderSongs(array $tracks, bool $injectAlbums=true) : array {
1817
		if ($injectAlbums) {
1818
			$userId = $this->session->getUserId();
1819
			$this->albumBusinessLayer->injectAlbumsToTracks($tracks, $userId);
1820
		}
1821
1822
		$createPlayUrl = function(Track $track) : string {
1823
			return $this->createAmpacheActionUrl('download', $track->getId());
1824
		};
1825
		$createImageUrl = function(Track $track) : string {
1826
			$album = $track->getAlbum();
1827
			return ($album !== null) ? $this->createCoverUrl($album) : '';
1828
		};
1829
		$renderRef = function(int $id, string $name) : array {
1830
			return $this->renderAlbumOrArtistRef($id, $name);
1831
		};
1832
		$genreKey = $this->genreKey();
1833
		// In APIv6 JSON format, there is a new property `artists` with an array value
1834
		$includeArtists = ($this->jsonMode && $this->apiMajorVersion() > 5);
1835
1836
		return [
1837
			'song' => Util::arrayMapMethod($tracks, 'toAmpacheApi', 
1838
				[$this->l10n, $createPlayUrl, $createImageUrl, $renderRef, $genreKey, $includeArtists])
1839
		];
1840
	}
1841
1842
	/**
1843
	 * @param Playlist[] $playlists
1844
	 */
1845
	private function renderPlaylists(array $playlists) : array {
1846
		$createImageUrl = function(Playlist $playlist) : string {
1847
			if ($playlist->getId() === self::ALL_TRACKS_PLAYLIST_ID) {
1848
				return '';
1849
			} else {
1850
				return $this->createCoverUrl($playlist);
1851
			}
1852
		};
1853
1854
		return [
1855
			'playlist' => Util::arrayMapMethod($playlists, 'toAmpacheApi', [$createImageUrl])
1856
		];
1857
	}
1858
1859
	/**
1860
	 * @param PodcastChannel[] $channels
1861
	 */
1862
	private function renderPodcastChannels(array $channels) : array {
1863
		return [
1864
			'podcast' => Util::arrayMapMethod($channels, 'toAmpacheApi')
1865
		];
1866
	}
1867
1868
	/**
1869
	 * @param PodcastEpisode[] $episodes
1870
	 */
1871
	private function renderPodcastEpisodes(array $episodes) : array {
1872
		return [
1873
			'podcast_episode' => Util::arrayMapMethod($episodes, 'toAmpacheApi')
1874
		];
1875
	}
1876
1877
	/**
1878
	 * @param RadioStation[] $stations
1879
	 */
1880
	private function renderLiveStreams(array $stations) : array {
1881
		return [
1882
			'live_stream' => Util::arrayMapMethod($stations, 'toAmpacheApi')
1883
		];
1884
	}
1885
1886
	/**
1887
	 * @param Genre[] $genres
1888
	 */
1889
	private function renderTags(array $genres) : array {
1890
		return [
1891
			'tag' => Util::arrayMapMethod($genres, 'toAmpacheApi', [$this->l10n])
1892
		];
1893
	}
1894
1895
	/**
1896
	 * @param Genre[] $genres
1897
	 */
1898
	private function renderGenres(array $genres) : array {
1899
		return [
1900
			'genre' => Util::arrayMapMethod($genres, 'toAmpacheApi', [$this->l10n])
1901
		];
1902
	}
1903
1904
	/**
1905
	 * @param Bookmark[] $bookmarks
1906
	 */
1907
	private function renderBookmarks(array $bookmarks, int $include=0) : array {
1908
		$renderEntry = null;
1909
1910
		if ($include) {
1911
			$renderEntry = function(string $type, int $id) {
1912
				$businessLayer = $this->getBusinessLayer($type);
1913
				$entity = $businessLayer->find($id, $this->session->getUserId());
1914
				return $this->renderEntities([$entity], $type)[$type][0];
1915
			};
1916
		}
1917
1918
		return [
1919
			'bookmark' => Util::arrayMapMethod($bookmarks, 'toAmpacheApi', [$renderEntry])
1920
		];
1921
	}
1922
1923
	/**
1924
	 * @param Track[] $tracks
1925
	 */
1926
	private function renderSongsIndex(array $tracks) : array {
1927
		return [
1928
			'song' => \array_map(function ($track) {
1929
				return [
1930
					'id' => (string)$track->getId(),
1931
					'title' => $track->getTitle(),
1932
					'name' => $track->getTitle(),
1933
					'artist' => $this->renderAlbumOrArtistRef($track->getArtistId(), $track->getArtistNameString($this->l10n)),
1934
					'album' => $this->renderAlbumOrArtistRef($track->getAlbumId(), $track->getAlbumNameString($this->l10n))
1935
				];
1936
			}, $tracks)
1937
		];
1938
	}
1939
1940
	/**
1941
	 * @param Album[] $albums
1942
	 */
1943
	private function renderAlbumsIndex(array $albums) : array {
1944
		return [
1945
			'album' => \array_map(function ($album) {
1946
				$name = $album->getNameString($this->l10n);
1947
				$nameParts = $this->prefixAndBaseName($name);
1948
1949
				return [
1950
					'id' => (string)$album->getId(),
1951
					'name' => $name,
1952
					'prefix' => $nameParts['prefix'],
1953
					'basename' => $nameParts['basename'],
1954
					'artist' => $this->renderAlbumOrArtistRef($album->getAlbumArtistId(), $album->getAlbumArtistNameString($this->l10n))
1955
				];
1956
			}, $albums)
1957
		];
1958
	}
1959
1960
	/**
1961
	 * @param Artist[] $artists
1962
	 */
1963
	private function renderArtistsIndex(array $artists) : array {
1964
		return [
1965
			'artist' => \array_map(function ($artist) {
1966
				$userId = $this->session->getUserId();
1967
				$albums = $this->albumBusinessLayer->findAllByArtist($artist->getId(), $userId);
1968
				$name = $artist->getNameString($this->l10n);
1969
				$nameParts = $this->prefixAndBaseName($name);
1970
1971
				return [
1972
					'id' => (string)$artist->getId(),
1973
					'name' => $name,
1974
					'prefix' => $nameParts['prefix'],
1975
					'basename' => $nameParts['basename'],
1976
					'album' => \array_map(function ($album) {
1977
						return $this->renderAlbumOrArtistRef($album->getId(), $album->getNameString($this->l10n));
1978
					}, $albums)
1979
				];
1980
			}, $artists)
1981
		];
1982
	}
1983
1984
	/**
1985
	 * @param Playlist[] $playlists
1986
	 */
1987
	private function renderPlaylistsIndex(array $playlists) : array {
1988
		return [
1989
			'playlist' => \array_map(function ($playlist) {
1990
				return [
1991
					'id' => (string)$playlist->getId(),
1992
					'name' => $playlist->getName(),
1993
					'playlisttrack' => $playlist->getTrackIdsAsArray()
1994
				];
1995
			}, $playlists)
1996
		];
1997
	}
1998
1999
	/**
2000
	 * @param PodcastChannel[] $channels
2001
	 */
2002
	private function renderPodcastChannelsIndex(array $channels) : array {
2003
		// The v4 API spec does not give any examples of this, and the v5 example is almost identical to the v4 "normal" result
2004
		return $this->renderPodcastChannels($channels);
2005
	}
2006
2007
	/**
2008
	 * @param PodcastEpisode[] $episodes
2009
	 */
2010
	private function renderPodcastEpisodesIndex(array $episodes) : array {
2011
		// The v4 API spec does not give any examples of this, and the v5 example is almost identical to the v4 "normal" result
2012
		return $this->renderPodcastEpisodes($episodes);
2013
	}
2014
2015
	/**
2016
	 * @param RadioStation[] $stations
2017
	 */
2018
	private function renderLiveStreamsIndex(array $stations) : array {
2019
		// The API spec gives no examples of this, but testing with Ampache demo server revealed that the format is indentical to the "full" format
2020
		return $this->renderLiveStreams($stations);
2021
	}
2022
2023
	/**
2024
	 * @param Entity[] $entities
2025
	 */
2026
	private function renderEntityIds(array $entities) : array {
2027
		return ['id' => Util::extractIds($entities)];
2028
	}
2029
2030
	/**
2031
	 * Array is considered to be "indexed" if its first element has numerical key.
2032
	 * Empty array is considered to be "indexed".
2033
	 */
2034
	private static function arrayIsIndexed(array $array) : bool {
2035
		\reset($array);
2036
		return empty($array) || \is_int(\key($array));
2037
	}
2038
2039
	/**
2040
	 * The JSON API has some asymmetries with the XML API. This function makes the needed
2041
	 * translations for the result content before it is converted into JSON.
2042
	 */
2043
	private function prepareResultForJsonApi(array $content) : array {
2044
		$apiVer = $this->apiMajorVersion();
2045
2046
		// Special handling is needed for responses returning an array of library entities,
2047
		// depending on the API version. In these cases, the outermost array is of associative
2048
		// type with a single value which is a non-associative array.
2049
		if (\count($content) === 1 && !self::arrayIsIndexed($content)
2050
				&& \is_array(\current($content)) && self::arrayIsIndexed(\current($content))) {
2051
			// In API versions < 5, the root node is an anonymous array. Unwrap the outermost array.
2052
			if ($apiVer < 5) {
2053
				$content = \array_pop($content);
2054
			}
2055
			// In later versions, the root object has a named array for plural actions (like "songs", "artists").
2056
			// For singular actions (like "song", "artist"), the root object contains directly the entity properties.
2057
			else {
2058
				$action = $this->request->getParam('action');
2059
				$plural = (\substr($action, -1) === 's' || \in_array($action, ['get_similar', 'advanced_search', 'list']));
2060
2061
				// In APIv5, the action "album" is an excption, it is formatted as if it was a plural action.
2062
				// This outlier has been fixed in APIv6.
2063
				$api5albumOddity = ($apiVer === 5 && $action === 'album');
2064
2065
				// The actions "user_preference" and "system_preference" are another kind of outliers in APIv5,
2066
				// their reponses are anonymou 1-item arrays. This got fixed in the APIv6.0.1
2067
				$api5preferenceOddity = ($apiVer === 5 && Util::endsWith($action, 'preference'));
2068
2069
				if ($api5preferenceOddity) {
2070
					$content = \array_pop($content);
2071
				} elseif (!($plural  || $api5albumOddity)) {
2072
					$content = \array_pop($content);
2073
					$content = \array_pop($content);
2074
				}
2075
			}
2076
		}
2077
2078
		// In API versions < 6, all boolean valued properties should be converted to 0/1.
2079
		if ($apiVer < 6) {
2080
			Util::intCastArrayValues($content, 'is_bool');
2081
		}
2082
2083
		// The key 'text' has a special meaning on XML responses, as it makes the corresponding value
2084
		// to be treated as text content of the parent element. In the JSON API, these are mostly
2085
		// substituted with property 'name', but error responses use the property 'message', instead.
2086
		if (\array_key_exists('error', $content)) {
2087
			$content = Util::convertArrayKeys($content, ['text' => 'message']);
2088
		} else {
2089
			$content = Util::convertArrayKeys($content, ['text' => 'name']);
2090
		}
2091
		return $content;
2092
	}
2093
2094
	/**
2095
	 * The XML API has some asymmetries with the JSON API. This function makes the needed
2096
	 * translations for the result content before it is converted into XML.
2097
	 */
2098
	private function prepareResultForXmlApi(array $content) : array {
2099
		\reset($content);
2100
		$firstKey = \key($content);
2101
2102
		// all 'entity list' kind of responses shall have the (deprecated) total_count element
2103
		if ($firstKey == 'song' || $firstKey == 'album' || $firstKey == 'artist' || $firstKey == 'playlist'
2104
				|| $firstKey == 'tag' || $firstKey == 'genre' || $firstKey == 'podcast' || $firstKey == 'podcast_episode'
2105
				|| $firstKey == 'live_stream') {
2106
			$content = ['total_count' => \count($content[$firstKey])] + $content;
2107
		}
2108
2109
		// for some bizarre reason, the 'id' arrays have 'index' attributes in the XML format
2110
		if ($firstKey == 'id') {
2111
			$content['id'] = \array_map(function ($id, $index) {
2112
				return ['index' => $index, 'text' => $id];
2113
			}, $content['id'], \array_keys($content['id']));
2114
		}
2115
2116
		return ['root' => $content];
2117
	}
2118
2119
	private function genreKey() : string {
2120
		return ($this->apiMajorVersion() > 4) ? 'genre' : 'tag';
2121
	}
2122
2123
	private function apiMajorVersion() : int {
2124
		// During the handshake, we don't yet have a session but the requeted version may be in the request args
2125
		$verString = ($this->session !== null) 
2126
			? $this->session->getApiVersion()
2127
			: $this->request->getParam('version');
2128
		
2129
		if (\is_string($verString) && \strlen($verString)) {
2130
			$ver = (int)$verString[0];
2131
		} else {
2132
			// Default version is 6 unless otherwise defined in config.php
2133
			$ver = (int)$this->config->getSystemValue('music.ampache_api_default_ver', 6);
2134
		}
2135
2136
		// For now, we have three supported major versions. Major version 3 can be sufficiently supported
2137
		// with our "version 4" implementation.
2138
		return (int)Util::limit($ver, 4, 6);
2139
	}
2140
2141
	private function apiVersionString() : string {
2142
		switch ($this->apiMajorVersion()) {
2143
			case 4:		return self::API4_VERSION;
2144
			case 5:		return self::API5_VERSION;
2145
			case 6:		return self::API6_VERSION;
2146
			default:	throw new AmpacheException('Unexpected api major version', 500);
2147
		}
2148
	}
2149
2150
	private function mapApiV4ErrorToV5(int $code) : int {
2151
		switch ($code) {
2152
			case 400:	return 4710;	// bad request
2153
			case 401:	return 4701;	// invalid handshake
2154
			case 403:	return 4703;	// access denied
2155
			case 404:	return 4704;	// not found
2156
			case 405:	return 4705;	// missing
2157
			case 412:	return 4742;	// failed access check
2158
			case 501:	return 4700;	// access control not enabled
2159
			default:	return 5000;	// unexcpected (not part of the API spec)
2160
		}
2161
	}
2162
}
2163
2164
/**
2165
 * Adapter class which acts like the Playlist class for the purpose of
2166
 * AmpacheController::renderPlaylists but contains all the track of the user.
2167
 */
2168
class AmpacheController_AllTracksPlaylist extends Playlist {
2169
	private $trackBusinessLayer;
2170
	private $l10n;
2171
2172
	public function __construct(string $userId, TrackBusinessLayer $trackBusinessLayer, IL10N $l10n) {
2173
		$this->userId = $userId;
2174
		$this->trackBusinessLayer = $trackBusinessLayer;
2175
		$this->l10n = $l10n;
2176
	}
2177
2178
	public function getId() : int {
2179
		return AmpacheController::ALL_TRACKS_PLAYLIST_ID;
2180
	}
2181
2182
	public function getName() : string {
2183
		return $this->l10n->t('All tracks');
2184
	}
2185
2186
	public function getTrackCount() : int {
2187
		return $this->trackBusinessLayer->count($this->userId);
2188
	}
2189
}
2190