Passed
Pull Request — master (#1078)
by Pauli
05:37 queued 02:44
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 - 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 = '600001';
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' => $this->getAppNameAndVersion(),
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) : array {
685
		$userId = $this->session->getUserId();
686
		if ($filter == self::ALL_TRACKS_PLAYLIST_ID) {
687
			$tracks = $this->trackBusinessLayer->findAll($userId, SortBy::Parent, $limit, $offset);
688
			foreach ($tracks as $index => &$track) {
689
				$track->setNumberOnPlaylist($index + 1);
690
			}
691
		} else {
692
			$tracks = $this->playlistBusinessLayer->getPlaylistTracks($filter, $userId, $limit, $offset);
693
		}
694
		return $this->renderSongs($tracks);
695
	}
696
697
	/**
698
	 * @AmpacheAPI
699
	 */
700
	protected function playlist_create(string $name) : array {
701
		$playlist = $this->playlistBusinessLayer->create($name, $this->session->getUserId());
702
		return $this->renderPlaylists([$playlist]);
703
	}
704
705
	/**
706
	 * @AmpacheAPI
707
	 *
708
	 * @param int $filter Playlist ID
709
	 * @param ?string $name New name for the playlist
710
	 * @param ?string $items Track IDs
711
	 * @param ?string $tracks 1-based indices of the tracks
712
	 */
713
	protected function playlist_edit(int $filter, ?string $name, ?string $items, ?string $tracks) : array {
714
		$edited = false;
715
		$userId = $this->session->getUserId();
716
		$playlist = $this->playlistBusinessLayer->find($filter, $userId);
717
718
		if (!empty($name)) {
719
			$playlist->setName($name);
720
			$edited = true;
721
		}
722
723
		$newTrackIds = Util::explode(',', $items);
724
		$newTrackOrdinals = Util::explode(',', $tracks);
725
726
		if (\count($newTrackIds) != \count($newTrackOrdinals)) {
727
			throw new AmpacheException("Arguments 'items' and 'tracks' must contain equal amount of elements", 400);
728
		} elseif (\count($newTrackIds) > 0) {
729
			$trackIds = $playlist->getTrackIdsAsArray();
730
731
			for ($i = 0, $count = \count($newTrackIds); $i < $count; ++$i) {
732
				$trackId = $newTrackIds[$i];
733
				if (!$this->trackBusinessLayer->exists($trackId, $userId)) {
734
					throw new AmpacheException("Invalid song ID $trackId", 404);
735
				}
736
				$trackIds[$newTrackOrdinals[$i]-1] = $trackId;
737
			}
738
739
			$playlist->setTrackIdsFromArray($trackIds);
740
			$edited = true;
741
		}
742
743
		if ($edited) {
744
			$this->playlistBusinessLayer->update($playlist);
745
			return ['success' => 'playlist changes saved'];
746
		} else {
747
			throw new AmpacheException('Nothing was changed', 400);
748
		}
749
	}
750
751
	/**
752
	 * @AmpacheAPI
753
	 */
754
	protected function playlist_delete(int $filter) : array {
755
		$this->playlistBusinessLayer->delete($filter, $this->session->getUserId());
756
		return ['success' => 'playlist deleted'];
757
	}
758
759
	/**
760
	 * @AmpacheAPI
761
	 */
762
	protected function playlist_add_song(int $filter, int $song, bool $check=false) : array {
763
		$userId = $this->session->getUserId();
764
		if (!$this->trackBusinessLayer->exists($song, $userId)) {
765
			throw new AmpacheException("Invalid song ID $song", 404);
766
		}
767
768
		$playlist = $this->playlistBusinessLayer->find($filter, $userId);
769
		$trackIds = $playlist->getTrackIdsAsArray();
770
771
		if ($check && \in_array($song, $trackIds)) {
772
			throw new AmpacheException("Can't add a duplicate item when check is enabled", 400);
773
		}
774
775
		$trackIds[] = $song;
776
		$playlist->setTrackIdsFromArray($trackIds);
777
		$this->playlistBusinessLayer->update($playlist);
778
		return ['success' => 'song added to playlist'];
779
	}
780
781
	/**
782
	 * @AmpacheAPI
783
	 *
784
	 * @param int $filter Playlist ID
785
	 * @param ?int $song Track ID
786
	 * @param ?int $track 1-based index of the track
787
	 * @param ?int $clear Value 1 erases all the songs from the playlist
788
	 */
789
	protected function playlist_remove_song(int $filter, ?int $song, ?int $track, ?int $clear) : array {
790
		$playlist = $this->playlistBusinessLayer->find($filter, $this->session->getUserId());
791
792
		if ($clear === 1) {
793
			$trackIds = [];
794
			$message = 'all songs removed from playlist';
795
		} elseif ($song !== null) {
796
			$trackIds = $playlist->getTrackIdsAsArray();
797
			if (!\in_array($song, $trackIds)) {
798
				throw new AmpacheException("Song $song not found in playlist", 404);
799
			}
800
			$trackIds = Util::arrayDiff($trackIds, [$song]);
801
			$message = 'song removed from playlist';
802
		} elseif ($track !== null) {
803
			$trackIds = $playlist->getTrackIdsAsArray();
804
			if ($track < 1 || $track > \count($trackIds)) {
805
				throw new AmpacheException("Track ordinal $track is out of bounds", 404);
806
			}
807
			unset($trackIds[$track-1]);
808
			$message = 'song removed from playlist';
809
		} else {
810
			throw new AmpacheException("One of the arguments 'clear', 'song', 'track' is required", 400);
811
		}
812
813
		$playlist->setTrackIdsFromArray($trackIds);
814
		$this->playlistBusinessLayer->update($playlist);
815
		return ['success' => $message];
816
	}
817
818
	/**
819
	 * @AmpacheAPI
820
	 */
821
	protected function playlist_generate(
822
			?string $filter, ?int $album, ?int $artist, ?int $flag,
823
			int $limit, int $offset=0, string $mode='random', string $format='song') : array {
824
825
		$tracks = $this->findEntities($this->trackBusinessLayer, $filter, false); // $limit and $offset are applied later
826
827
		// filter the found tracks according to the additional requirements
828
		if ($album !== null) {
829
			$tracks = \array_filter($tracks, function ($track) use ($album) {
830
				return ($track->getAlbumId() == $album);
831
			});
832
		}
833
		if ($artist !== null) {
834
			$tracks = \array_filter($tracks, function ($track) use ($artist) {
835
				return ($track->getArtistId() == $artist);
836
			});
837
		}
838
		if ($flag == 1) {
839
			$tracks = \array_filter($tracks, function ($track) {
840
				return ($track->getStarred() !== null);
841
			});
842
		}
843
		// After filtering, there may be "holes" between the array indices. Reindex the array.
844
		$tracks = \array_values($tracks);
845
846
		if ($mode == 'random') {
847
			$userId = $this->session->getUserId();
848
			$indices = $this->random->getIndices(\count($tracks), $offset, $limit, $userId, 'ampache_playlist_generate');
849
			$tracks = Util::arrayMultiGet($tracks, $indices);
850
		} else { // 'recent', 'forgotten', 'unplayed'
851
			throw new AmpacheException("Mode '$mode' is not supported", 400);
852
		}
853
854
		switch ($format) {
855
			case 'song':
856
				return $this->renderSongs($tracks);
857
			case 'index':
858
				return $this->renderSongsIndex($tracks);
859
			case 'id':
860
				return $this->renderEntityIds($tracks);
861
			default:
862
				throw new AmpacheException("Format '$format' is not supported", 400);
863
		}
864
	}
865
866
	/**
867
	 * @AmpacheAPI
868
	 */
869
	protected function podcasts(?string $filter, ?string $include, int $limit, int $offset=0, bool $exact=false) : array {
870
		$channels = $this->findEntities($this->podcastChannelBusinessLayer, $filter, $exact, $limit, $offset);
871
872
		if ($include === 'episodes') {
873
			$this->injectEpisodesToChannels($channels);
874
		}
875
876
		return $this->renderPodcastChannels($channels);
877
	}
878
879
	/**
880
	 * @AmpacheAPI
881
	 */
882
	protected function podcast(int $filter, ?string $include) : array {
883
		$userId = $this->session->getUserId();
884
		$channel = $this->podcastChannelBusinessLayer->find($filter, $userId);
885
886
		if ($include === 'episodes') {
887
			$channel->setEpisodes($this->podcastEpisodeBusinessLayer->findAllByChannel($filter, $userId));
888
		}
889
890
		return $this->renderPodcastChannels([$channel]);
891
	}
892
893
	/**
894
	 * @AmpacheAPI
895
	 */
896
	protected function podcast_create(string $url) : array {
897
		$userId = $this->session->getUserId();
898
		$result = $this->podcastService->subscribe($url, $userId);
899
900
		switch ($result['status']) {
901
			case PodcastService::STATUS_OK:
902
				return $this->renderPodcastChannels([$result['channel']]);
903
			case PodcastService::STATUS_INVALID_URL:
904
				throw new AmpacheException("Invalid URL $url", 400);
905
			case PodcastService::STATUS_INVALID_RSS:
906
				throw new AmpacheException("The document at URL $url is not a valid podcast RSS feed", 400);
907
			case PodcastService::STATUS_ALREADY_EXISTS:
908
				throw new AmpacheException('User already has this podcast channel subscribed', 400);
909
			default:
910
				throw new AmpacheException("Unexpected status code {$result['status']}", 400);
911
		}
912
	}
913
914
	/**
915
	 * @AmpacheAPI
916
	 */
917
	protected function podcast_delete(int $filter) : array {
918
		$userId = $this->session->getUserId();
919
		$status = $this->podcastService->unsubscribe($filter, $userId);
920
921
		switch ($status) {
922
			case PodcastService::STATUS_OK:
923
				return ['success' => 'podcast deleted'];
924
			case PodcastService::STATUS_NOT_FOUND:
925
				throw new AmpacheException('Channel to be deleted not found', 404);
926
			default:
927
				throw new AmpacheException("Unexpected status code $status", 400);
928
		}
929
	}
930
931
	/**
932
	 * @AmpacheAPI
933
	 */
934
	protected function podcast_episodes(int $filter, int $limit, int $offset=0) : array {
935
		$userId = $this->session->getUserId();
936
		$episodes = $this->podcastEpisodeBusinessLayer->findAllByChannel($filter, $userId, $limit, $offset);
937
		return $this->renderPodcastEpisodes($episodes);
938
	}
939
940
	/**
941
	 * @AmpacheAPI
942
	 */
943
	protected function podcast_episode(int $filter) : array {
944
		$userId = $this->session->getUserId();
945
		$episode = $this->podcastEpisodeBusinessLayer->find($filter, $userId);
946
		return $this->renderPodcastEpisodes([$episode]);
947
	}
948
949
	/**
950
	 * @AmpacheAPI
951
	 */
952
	protected function update_podcast(int $id) : array {
953
		$userId = $this->session->getUserId();
954
		$result = $this->podcastService->updateChannel($id, $userId);
955
956
		switch ($result['status']) {
957
			case PodcastService::STATUS_OK:
958
				$message = $result['updated'] ? 'channel was updated from the source' : 'no changes found';
959
				return ['success' => $message];
960
			case PodcastService::STATUS_NOT_FOUND:
961
				throw new AmpacheException('Channel to be updated not found', 404);
962
			case PodcastService::STATUS_INVALID_URL:
963
				throw new AmpacheException('failed to read from the channel URL', 400);
964
			case PodcastService::STATUS_INVALID_RSS:
965
				throw new AmpacheException('the document at the channel URL is not a valid podcast RSS feed', 400);
966
			default:
967
				throw new AmpacheException("Unexpected status code {$result['status']}", 400);
968
		}
969
	}
970
971
	/**
972
	 * @AmpacheAPI
973
	 */
974
	protected function live_streams(?string $filter, int $limit, int $offset=0, bool $exact=false) : array {
975
		$stations = $this->findEntities($this->radioStationBusinessLayer, $filter, $exact, $limit, $offset);
976
		return $this->renderLiveStreams($stations);
977
	}
978
979
	/**
980
	 * @AmpacheAPI
981
	 */
982
	protected function live_stream(int $filter) : array {
983
		$station = $this->radioStationBusinessLayer->find($filter, $this->session->getUserId());
984
		return $this->renderLiveStreams([$station]);
985
	}
986
987
	/**
988
	 * @AmpacheAPI
989
	 */
990
	protected function live_stream_create(string $name, string $url, ?string $site_url) : array {
991
		$station = $this->radioStationBusinessLayer->create($this->session->getUserId(), $name, $url, $site_url);
992
		return $this->renderLiveStreams([$station]);
993
	}
994
995
	/**
996
	 * @AmpacheAPI
997
	 */
998
	protected function live_stream_delete(int $filter) : array {
999
		$this->radioStationBusinessLayer->delete($filter, $this->session->getUserId());
1000
		return ['success' => "Deleted live stream: $filter"];
1001
	}
1002
1003
	/**
1004
	 * @AmpacheAPI
1005
	 */
1006
	protected function live_stream_edit(int $filter, ?string $name, ?string $url, ?string $site_url) : array {
1007
		$station = $this->radioStationBusinessLayer->find($filter, $this->session->getUserId());
1008
1009
		if ($name !== null) {
1010
			$station->setName($name);
1011
		}
1012
		if ($url !== null) {
1013
			$station->setStreamUrl($url);
1014
		}
1015
		if ($site_url !== null) {
1016
			$station->setHomeUrl($site_url);
1017
		}
1018
		$station = $this->radioStationBusinessLayer->update($station);
1019
1020
		return $this->renderLiveStreams([$station]);
1021
	}
1022
1023
	/**
1024
	 * @AmpacheAPI
1025
	 */
1026
	protected function tags(?string $filter, int $limit, int $offset=0, bool $exact=false) : array {
1027
		$genres = $this->findEntities($this->genreBusinessLayer, $filter, $exact, $limit, $offset);
1028
		return $this->renderTags($genres);
1029
	}
1030
1031
	/**
1032
	 * @AmpacheAPI
1033
	 */
1034
	protected function tag(int $filter) : array {
1035
		$userId = $this->session->getUserId();
1036
		$genre = $this->genreBusinessLayer->find($filter, $userId);
1037
		return $this->renderTags([$genre]);
1038
	}
1039
1040
	/**
1041
	 * @AmpacheAPI
1042
	 */
1043
	protected function tag_artists(int $filter, int $limit, int $offset=0) : array {
1044
		$userId = $this->session->getUserId();
1045
		$artists = $this->artistBusinessLayer->findAllByGenre($filter, $userId, $limit, $offset);
1046
		return $this->renderArtists($artists);
1047
	}
1048
1049
	/**
1050
	 * @AmpacheAPI
1051
	 */
1052
	protected function tag_albums(int $filter, int $limit, int $offset=0) : array {
1053
		$userId = $this->session->getUserId();
1054
		$albums = $this->albumBusinessLayer->findAllByGenre($filter, $userId, $limit, $offset);
1055
		return $this->renderAlbums($albums);
1056
	}
1057
1058
	/**
1059
	 * @AmpacheAPI
1060
	 */
1061
	protected function tag_songs(int $filter, int $limit, int $offset=0) : array {
1062
		$userId = $this->session->getUserId();
1063
		$tracks = $this->trackBusinessLayer->findAllByGenre($filter, $userId, $limit, $offset);
1064
		return $this->renderSongs($tracks);
1065
	}
1066
1067
	/**
1068
	 * @AmpacheAPI
1069
	 */
1070
	protected function genres(?string $filter, int $limit, int $offset=0, bool $exact=false) : array {
1071
		$genres = $this->findEntities($this->genreBusinessLayer, $filter, $exact, $limit, $offset);
1072
		return $this->renderGenres($genres);
1073
	}
1074
1075
	/**
1076
	 * @AmpacheAPI
1077
	 */
1078
	protected function genre(int $filter) : array {
1079
		$userId = $this->session->getUserId();
1080
		$genre = $this->genreBusinessLayer->find($filter, $userId);
1081
		return $this->renderGenres([$genre]);
1082
	}
1083
1084
	/**
1085
	 * @AmpacheAPI
1086
	 */
1087
	protected function genre_artists(?int $filter, int $limit, int $offset=0) : array {
1088
		if ($filter === null) {
1089
			return $this->artists(null, null, null, $limit, $offset);
1090
		} else {
1091
			return $this->tag_artists($filter, $limit, $offset);
1092
		}
1093
	}
1094
1095
	/**
1096
	 * @AmpacheAPI
1097
	 */
1098
	protected function genre_albums(?int $filter, int $limit, int $offset=0) : array {
1099
		if ($filter === null) {
1100
			return $this->albums(null, null, null, $limit, $offset);
1101
		} else {
1102
			return $this->tag_albums($filter, $limit, $offset);
1103
		}
1104
	}
1105
1106
	/**
1107
	 * @AmpacheAPI
1108
	 */
1109
	protected function genre_songs(?int $filter, int $limit, int $offset=0) : array {
1110
		if ($filter === null) {
1111
			return $this->songs(null, null, null, $limit, $offset);
1112
		} else {
1113
			return $this->tag_songs($filter, $limit, $offset);
1114
		}
1115
	}
1116
1117
	/**
1118
	 * @AmpacheAPI
1119
	 */
1120
	protected function bookmarks() : array {
1121
		$bookmarks = $this->bookmarkBusinessLayer->findAll($this->session->getUserId());
1122
		return $this->renderBookmarks($bookmarks);
1123
	}
1124
1125
	/**
1126
	 * @AmpacheAPI
1127
	 */
1128
	protected function get_bookmark(int $filter, string $type) : array {
1129
		$entryType = self::mapBookmarkType($type);
1130
		$bookmark = $this->bookmarkBusinessLayer->findByEntry($entryType, $filter, $this->session->getUserId());
1131
		return $this->renderBookmarks([$bookmark]);
1132
	}
1133
1134
	/**
1135
	 * @AmpacheAPI
1136
	 */
1137
	protected function bookmark_create(int $filter, string $type, int $position, string $client='AmpacheAPI') : array {
1138
		// Note: the optional argument 'date' is not supported and is disregarded
1139
		$entryType = self::mapBookmarkType($type);
1140
		$position *= 1000; // seconds to milliseconds
1141
		$bookmark = $this->bookmarkBusinessLayer->addOrUpdate($this->session->getUserId(), $entryType, $filter, $position, $client);
1142
		return $this->renderBookmarks([$bookmark]);
1143
	}
1144
1145
	/**
1146
	 * @AmpacheAPI
1147
	 */
1148
	protected function bookmark_edit(int $filter, string $type, int $position, ?string $client) : array {
1149
		// Note: the optional argument 'date' is not supported and is disregarded
1150
		$entryType = self::mapBookmarkType($type);
1151
		$bookmark = $this->bookmarkBusinessLayer->findByEntry($entryType, $filter, $this->session->getUserId());
1152
		$bookmark->setPosition($position * 1000); // seconds to milliseconds
1153
		if ($client !== null) {
1154
			$bookmark->setComment($client);
1155
		}
1156
		$bookmark = $this->bookmarkBusinessLayer->update($bookmark);
1157
		return $this->renderBookmarks([$bookmark]);
1158
	}
1159
1160
	/**
1161
	 * @AmpacheAPI
1162
	 */
1163
	protected function bookmark_delete(int $filter, string $type) : array {
1164
		$entryType = self::mapBookmarkType($type);
1165
		$bookmark = $this->bookmarkBusinessLayer->findByEntry($entryType, $filter, $this->session->getUserId());
1166
		$this->bookmarkBusinessLayer->delete($bookmark->getId(), $bookmark->getUserId());
1167
		return ['success' => "Deleted Bookmark: $type $filter"];
1168
	}
1169
1170
	/**
1171
	 * @AmpacheAPI
1172
	 */
1173
	protected function advanced_search(string $type, string $operator, int $limit, int $offset=0, bool $random=false) : array {
1174
		// get all the rule parameters as passed on the HTTP call
1175
		$rules = self::advSearchGetRuleParams($this->request->getParams());
1176
1177
		// apply some conversions on the rules
1178
		foreach ($rules as &$rule) {
1179
			$rule['rule'] = self::advSearchResolveRuleAlias($rule['rule']);
1180
			$rule['operator'] = self::advSearchInterpretOperator($rule['operator'], $rule['rule']);
1181
			$rule['input'] = self::advSearchConvertInput($rule['input'], $rule['rule']);
1182
		}
1183
1184
		// types 'album_artist' and 'song_artist' are just 'artist' searches with some extra conditions
1185
		if ($type == 'album_artist') {
1186
			$rules[] = ['rule' => 'album_count', 'operator' => '>', 'input' => '0'];
1187
			$type = 'artist';
1188
		} elseif ($type == 'song_artist') {
1189
			$rules[] = ['rule' => 'song_count', 'operator' => '>', 'input' => '0'];
1190
			$type = 'artist';
1191
		}
1192
1193
		try {
1194
			$businessLayer = $this->getBusinessLayer($type);
1195
			$userId = $this->session->getUserId();
1196
			if ($random) {
1197
				// in case the random order is requested, the limit/offset handling happens after the DB query
1198
				$entities = $businessLayer->findAllAdvanced($operator, $rules, $userId);
1199
				$indices = $this->random->getIndices(\count($entities), $offset, $limit, $userId, 'ampache_adv_search_'.$type);
1200
				$entities = Util::arrayMultiGet($entities, $indices);
1201
			} else {
1202
				$entities = $businessLayer->findAllAdvanced($operator, $rules, $userId, $limit, $offset);
1203
			}
1204
		} catch (BusinessLayerException $e) {
1205
			throw new AmpacheException($e->getMessage(), 400);
1206
		}
1207
		
1208
		return $this->renderEntities($entities, $type);
1209
	}
1210
1211
	/**
1212
	 * @AmpacheAPI
1213
	 */
1214
	protected function flag(string $type, int $id, bool $flag) : array {
1215
		if (!\in_array($type, ['song', 'album', 'artist', 'podcast', 'podcast_episode', 'playlist'])) {
1216
			throw new AmpacheException("Unsupported type $type", 400);
1217
		}
1218
1219
		$userId = $this->session->getUserId();
1220
		$businessLayer = $this->getBusinessLayer($type);
1221
		if ($flag) {
1222
			$modifiedCount = $businessLayer->setStarred([$id], $userId);
1223
			$message = "flag ADDED to $type $id";
1224
		} else {
1225
			$modifiedCount = $businessLayer->unsetStarred([$id], $userId);
1226
			$message = "flag REMOVED from $type $id";
1227
		}
1228
1229
		if ($modifiedCount > 0) {
1230
			return ['success' => $message];
1231
		} else {
1232
			throw new AmpacheException("The $type $id was not found", 404);
1233
		}
1234
	}
1235
1236
	/**
1237
	 * @AmpacheAPI
1238
	 */
1239
	protected function rate(string $type, int $id, int $rating) : array {
1240
		$rating = Util::limit($rating, 0, 5);
1241
		$userId = $this->session->getUserId();
1242
		$businessLayer = $this->getBusinessLayer($type);
1243
		$entity = $businessLayer->find($id, $userId);
1244
		if (\property_exists($entity, 'rating')) {
1245
			// Scrutinizer and PHPStan don't understand the connection between the property 'rating' and the method 'setRating'
1246
			$entity->/** @scrutinizer ignore-call */setRating($rating); // @phpstan-ignore-line
1247
			$businessLayer->update($entity);
1248
		} else {
1249
			throw new AmpacheException("Unsupported type $type", 400);
1250
		}
1251
1252
		return ['success' => "rating set to $rating for $type $id"];
1253
	}
1254
1255
	/**
1256
	 * @AmpacheAPI
1257
	 */
1258
	protected function record_play(int $id, ?int $date) : array {
1259
		$timeOfPlay = ($date === null) ? null : new \DateTime('@' . $date);
1260
		$this->trackBusinessLayer->recordTrackPlayed($id, $this->session->getUserId(), $timeOfPlay);
1261
		return ['success' => 'play recorded'];
1262
	}
1263
1264
	/**
1265
	 * @AmpacheAPI
1266
	 */
1267
	protected function user_preferences() : array {
1268
		return ['user_preference' => AmpachePreferences::getAll()];
1269
	}
1270
1271
	/**
1272
	 * @AmpacheAPI
1273
	 */
1274
	protected function user_preference(string $filter) : array {
1275
		$pref = AmpachePreferences::get($filter);
1276
		if ($pref === null) {
1277
			throw new AmpacheException("Not Found: $filter", 400);
1278
		} else {
1279
			return ['user_preference' => [$pref]];
1280
		}
1281
	}
1282
1283
	/**
1284
	 * @AmpacheAPI
1285
	 */
1286
	protected function download(int $id, string $type='song') : Response {
1287
		// request param `format` is ignored
1288
		$userId = $this->session->getUserId();
1289
1290
		if ($type === 'song') {
1291
			try {
1292
				$track = $this->trackBusinessLayer->find($id, $userId);
1293
			} catch (BusinessLayerException $e) {
1294
				return new ErrorResponse(Http::STATUS_NOT_FOUND, $e->getMessage());
1295
			}
1296
1297
			$file = $this->librarySettings->getFolder($userId)->getById($track->getFileId())[0] ?? null;
1298
1299
			if ($file instanceof \OCP\Files\File) {
1300
				return new FileStreamResponse($file);
1301
			} else {
1302
				return new ErrorResponse(Http::STATUS_NOT_FOUND);
1303
			}
1304
		} elseif ($type === 'podcast' || $type === 'podcast_episode') { // there's a difference between APIv4 and APIv5
1305
			$episode = $this->podcastEpisodeBusinessLayer->find($id, $userId);
1306
			return new RedirectResponse($episode->getStreamUrl());
1307
		} elseif ($type === 'playlist') {
1308
			$songIds = ($id === self::ALL_TRACKS_PLAYLIST_ID)
1309
				? $this->trackBusinessLayer->findAllIds($userId)
1310
				: $this->playlistBusinessLayer->find($id, $userId)->getTrackIdsAsArray();
1311
			$randomId = Random::pickItem($songIds);
1312
			if ($randomId === null) {
1313
				throw new AmpacheException("The playlist $id is empty", 404);
1314
			} else {
1315
				return $this->download((int)$randomId);
1316
			}
1317
		} else {
1318
			throw new AmpacheException("Unsupported type '$type'", 400);
1319
		}
1320
	}
1321
1322
	/**
1323
	 * @AmpacheAPI
1324
	 */
1325
	protected function stream(int $id, ?int $offset, string $type='song') : Response {
1326
		// request params `bitrate`, `format`, and `length` are ignored
1327
1328
		// This is just a dummy implementation. We don't support transcoding or streaming
1329
		// from a time offset.
1330
		// All the other unsupported arguments are just ignored, but a request with an offset
1331
		// is responded with an error. This is becuase the client would probably work in an
1332
		// unexpected way if it thinks it's streaming from offset but actually it is streaming
1333
		// from the beginning of the file. Returning an error gives the client a chance to fallback
1334
		// to other methods of seeking.
1335
		if ($offset !== null) {
1336
			throw new AmpacheException('Streaming with time offset is not supported', 400);
1337
		}
1338
1339
		return $this->download($id, $type);
1340
	}
1341
1342
	/**
1343
	 * @AmpacheAPI
1344
	 */
1345
	protected function get_art(string $type, int $id) : Response {
1346
		if (!\in_array($type, ['song', 'album', 'artist', 'podcast', 'playlist'])) {
1347
			throw new AmpacheException("Unsupported type $type", 400);
1348
		}
1349
1350
		if ($type === 'song') {
1351
			// map song to its parent album
1352
			$id = $this->trackBusinessLayer->find($id, $this->session->getUserId())->getAlbumId();
1353
			$type = 'album';
1354
		}
1355
1356
		return $this->getCover($id, $this->getBusinessLayer($type));
1357
	}
1358
1359
	/********************
1360
	 * Helper functions *
1361
	 ********************/
1362
1363
	private function getBusinessLayer(string $type) : BusinessLayer {
1364
		switch ($type) {
1365
			case 'song':			return $this->trackBusinessLayer;
1366
			case 'album':			return $this->albumBusinessLayer;
1367
			case 'artist':			return $this->artistBusinessLayer;
1368
			case 'playlist':		return $this->playlistBusinessLayer;
1369
			case 'podcast':			return $this->podcastChannelBusinessLayer;
1370
			case 'podcast_episode':	return $this->podcastEpisodeBusinessLayer;
1371
			case 'live_stream':		return $this->radioStationBusinessLayer;
1372
			case 'tag':				return $this->genreBusinessLayer;
1373
			case 'genre':			return $this->genreBusinessLayer;
1374
			case 'bookmark':		return $this->bookmarkBusinessLayer;
1375
			default:				throw new AmpacheException("Unsupported type $type", 400);
1376
		}
1377
	}
1378
1379
	private function renderEntities(array $entities, string $type) : array {
1380
		switch ($type) {
1381
			case 'song':			return $this->renderSongs($entities);
1382
			case 'album':			return $this->renderAlbums($entities);
1383
			case 'artist':			return $this->renderArtists($entities);
1384
			case 'playlist':		return $this->renderPlaylists($entities);
1385
			case 'podcast':			return $this->renderPodcastChannels($entities);
1386
			case 'podcast_episode':	return $this->renderPodcastEpisodes($entities);
1387
			case 'live_stream':		return $this->renderLiveStreams($entities);
1388
			case 'tag':				return $this->renderTags($entities);
1389
			case 'genre':			return $this->renderGenres($entities);
1390
			case 'bookmark':		return $this->renderBookmarks($entities);
1391
			default:				throw new AmpacheException("Unsupported type $type", 400);
1392
		}
1393
	}
1394
1395
	private function renderEntitiesIndex($entities, $type) : array {
1396
		switch ($type) {
1397
			case 'song':			return $this->renderSongsIndex($entities);
1398
			case 'album':			return $this->renderAlbumsIndex($entities);
1399
			case 'artist':			return $this->renderArtistsIndex($entities);
1400
			case 'playlist':		return $this->renderPlaylistsIndex($entities);
1401
			case 'podcast':			return $this->renderPodcastChannelsIndex($entities);
1402
			case 'podcast_episode':	return $this->renderPodcastEpisodesIndex($entities);
1403
			case 'live_stream':		return $this->renderLiveStreamsIndex($entities);
1404
			case 'tag':				return $this->renderTags($entities); // not part of the API spec
1405
			case 'genre':			return $this->renderGenres($entities); // not part of the API spec
1406
			case 'bookmark':		return $this->renderBookmarks($entities); // not part of the API spec
1407
			default:				throw new AmpacheException("Unsupported type $type", 400);
1408
		}
1409
	}
1410
1411
	private static function mapBookmarkType(string $ampacheType) : int {
1412
		switch ($ampacheType) {
1413
			case 'song':			return Bookmark::TYPE_TRACK;
1414
			case 'podcast_episode':	return Bookmark::TYPE_PODCAST_EPISODE;
1415
			default:				throw new AmpacheException("Unsupported type $ampacheType", 400);
1416
		}
1417
	}
1418
1419
	private static function advSearchResolveRuleAlias(string $rule) : string {
1420
		switch ($rule) {
1421
			case 'name':					return 'title';
1422
			case 'song_title':				return 'song';
1423
			case 'album_title':				return 'album';
1424
			case 'artist_title':			return 'artist';
1425
			case 'podcast_title':			return 'podcast';
1426
			case 'podcast_episode_title':	return 'podcast_episode';
1427
			case 'album_artist_title':		return 'album_artist';
1428
			case 'song_artist_title':		return 'song_artist';
1429
			case 'tag':						return 'genre';
1430
			case 'song_tag':				return 'song_genre';
1431
			case 'album_tag':				return 'album_genre';
1432
			case 'artist_tag':				return 'artist_genre';
1433
			case 'no_tag':					return 'no_genre';
1434
			default:						return $rule;
1435
		}
1436
	}
1437
1438
	private static function advSearchGetRuleParams(array $urlParams) : array {
1439
		$rules = [];
1440
1441
		// read and organize the rule parameters
1442
		foreach ($urlParams as $key => $value) {
1443
			$parts = \explode('_', $key, 3);
1444
			if ($parts[0] == 'rule' && \count($parts) > 1) {
1445
				if (\count($parts) == 2) {
1446
					$rules[$parts[1]]['rule'] = $value;
1447
				} elseif ($parts[2] == 'operator') {
1448
					$rules[$parts[1]]['operator'] = (int)$value;
1449
				} elseif ($parts[2] == 'input') {
1450
					$rules[$parts[1]]['input'] = $value;
1451
				}
1452
			}
1453
		}
1454
1455
		// validate the rule parameters
1456
		if (\count($rules) === 0) {
1457
			throw new AmpacheException('At least one rule must be given', 400);
1458
		}
1459
		foreach ($rules as $rule) {
1460
			if (\count($rule) != 3) {
1461
				throw new AmpacheException('All rules must be given as triplet "rule_N", "rule_N_operator", "rule_N_input"', 400);
1462
			}
1463
		}
1464
1465
		return $rules;
1466
	}
1467
1468
	// NOTE: alias rule names should be resolved to their base form before calling this
1469
	private static function advSearchInterpretOperator(int $rule_operator, string $rule) : string {
1470
		// Operator mapping is different for text, numeric, date, boolean, and day rules
1471
1472
		$textRules = [
1473
			'anywhere', 'title', 'song', 'album', 'artist', 'podcast', 'podcast_episode', 'album_artist', 'song_artist',
1474
			'favorite', 'favorite_album', 'favorite_artist', 'genre', 'song_genre', 'album_genre', 'artist_genre',
1475
			'playlist_name', 'type', 'file', 'mbid', 'mbid_album', 'mbid_artist', 'mbid_song'
1476
		];
1477
		// text but no support planned: 'composer', 'summary', 'placeformed', 'release_type', 'release_status', 'barcode',
1478
		// 'catalog_number', 'label', 'comment', 'lyrics', 'username', 'category'
1479
1480
		$numericRules = [
1481
			'track', 'year', 'original_year', 'myrating', 'rating', 'songrating', 'albumrating', 'artistrating',
1482
			'played_times', 'album_count', 'song_count', 'time'
1483
		];
1484
		// numeric but no support planned: 'yearformed', 'skipped_times', 'play_skip_ratio', 'image_height', 'image_width'
1485
1486
		$numericLimitRules = ['recent_played', 'recent_added', 'recent_updated'];
1487
1488
		$dateOrDayRules = ['added', 'updated', 'pubdate', 'last_play'];
1489
1490
		$booleanRules = [
1491
			'played', 'myplayed', 'myplayedalbum', 'myplayedartist', 'has_image', 'no_genre',
1492
			'my_flagged', 'my_flagged_album', 'my_flagged_artist'
1493
		];
1494
		// boolean but no support planned: 'smartplaylist', 'possible_duplicate', 'possible_duplicate_album'
1495
1496
		$booleanNumericRules = ['playlist'];
1497
		// boolean numeric but no support planned: 'license', 'state', 'catalog'
1498
1499
		if (\in_array($rule, $textRules)) {
1500
			switch ($rule_operator) {
1501
				case 0: return 'contain';		// contains
1502
				case 1: return 'notcontain';	// does not contain;
1503
				case 2: return 'start';			// starts with
1504
				case 3: return 'end';			// ends with;
1505
				case 4: return 'is';			// is
1506
				case 5: return 'isnot';			// is not
1507
				case 6: return 'sounds';		// sounds like
1508
				case 7: return 'notsounds';		// does not sound like
1509
				case 8: return 'regexp';		// matches regex
1510
				case 9: return 'notregexp';		// does not match regex
1511
				default: throw new AmpacheException("Search operator '$rule_operator' not supported for 'text' type rules", 400);
1512
			}
1513
		} elseif (\in_array($rule, $numericRules)) {
1514
			switch ($rule_operator) {
1515
				case 0: return '>=';
1516
				case 1: return '<=';
1517
				case 2: return '=';
1518
				case 3: return '!=';
1519
				case 4: return '>';
1520
				case 5: return '<';
1521
				default: throw new AmpacheException("Search operator '$rule_operator' not supported for 'numeric' type rules", 400);
1522
			}
1523
		} elseif (\in_array($rule, $numericLimitRules)) {
1524
			return 'limit';
1525
		} elseif (\in_array($rule, $dateOrDayRules)) {
1526
			switch ($rule_operator) {
1527
				case 0: return '<';
1528
				case 1: return '>';
1529
				default: throw new AmpacheException("Search operator '$rule_operator' not supported for 'date' or 'day' type rules", 400);
1530
			}
1531
		} elseif (\in_array($rule, $booleanRules)) {
1532
			switch ($rule_operator) {
1533
				case 0: return 'true';
1534
				case 1: return 'false';
1535
				default: throw new AmpacheException("Search operator '$rule_operator' not supported for 'boolean' type rules", 400);
1536
			}
1537
		} elseif (\in_array($rule, $booleanNumericRules)) {
1538
			switch ($rule_operator) {
1539
				case 0: return 'equal';
1540
				case 1: return 'ne';
1541
				default: throw new AmpacheException("Search operator '$rule_operator' not supported for 'boolean numeric' type rules", 400);
1542
			}
1543
		} else {
1544
			throw new AmpacheException("Search rule '$rule' not supported", 400);
1545
		}
1546
	}
1547
1548
	private static function advSearchConvertInput(string $input, string $rule) {
1549
		switch ($rule) {
1550
			case 'last_play':
1551
				// days diff to ISO date
1552
				$date = new \DateTime("$input days ago");
1553
				return $date->format(BaseMapper::SQL_DATE_FORMAT);
1554
			case 'time':
1555
				// minutes to seconds
1556
				return (string)(int)((float)$input * 60);
1557
			default:
1558
				return $input;
1559
		}
1560
	}
1561
1562
	private function getAppNameAndVersion() : string {
1563
		$vendor = 'owncloud/nextcloud'; // this should get overridden by the next 'include'
1564
		include \OC::$SERVERROOT . '/version.php';
1565
1566
		$appVersion = AppInfo::getVersion();
1567
1568
		return "$vendor {$this->appName} $appVersion";
1569
	}
1570
1571
	private function getCover(int $entityId, BusinessLayer $businessLayer) : Response {
1572
		$userId = $this->session->getUserId();
1573
		$userFolder = $this->librarySettings->getFolder($userId);
1574
1575
		try {
1576
			$entity = $businessLayer->find($entityId, $userId);
1577
			$coverData = $this->coverHelper->getCover($entity, $userId, $userFolder);
1578
			if ($coverData !== null) {
1579
				return new FileResponse($coverData);
1580
			}
1581
		} catch (BusinessLayerException $e) {
1582
			return new ErrorResponse(Http::STATUS_NOT_FOUND, 'entity not found');
1583
		}
1584
1585
		return new ErrorResponse(Http::STATUS_NOT_FOUND, 'entity has no cover');
1586
	}
1587
1588
	private static function parseTimeParameters(?string $add=null, ?string $update=null) : array {
1589
		// It's not documented, but Ampache supports also specifying date range on `add` and `update` parameters
1590
		// by using '/' as separator. If there is no such separator, then the value is used as a lower limit.
1591
		$add = Util::explode('/', $add);
1592
		$update = Util::explode('/', $update);
1593
		$addMin = $add[0] ?? null;
1594
		$addMax = $add[1] ?? null;
1595
		$updateMin = $update[0] ?? null;
1596
		$updateMax = $update[1] ?? null;
1597
1598
		return [$addMin, $addMax, $updateMin, $updateMax];
1599
	}
1600
1601
	private function findEntities(
1602
			BusinessLayer $businessLayer, ?string $filter, bool $exact, ?int $limit=null, ?int $offset=null, ?string $add=null, ?string $update=null) : array {
1603
1604
		$userId = $this->session->getUserId();
1605
1606
		list($addMin, $addMax, $updateMin, $updateMax) = self::parseTimeParameters($add, $update);
1607
1608
		if ($filter) {
1609
			$matchMode = $exact ? MatchMode::Exact : MatchMode::Substring;
1610
			return $businessLayer->findAllByName($filter, $userId, $matchMode, $limit, $offset, $addMin, $addMax, $updateMin, $updateMax);
1611
		} else {
1612
			return $businessLayer->findAll($userId, SortBy::Name, $limit, $offset, $addMin, $addMax, $updateMin, $updateMax);
1613
		}
1614
	}
1615
1616
	/**
1617
	 * @param PodcastChannel[] &$channels
1618
	 */
1619
	private function injectEpisodesToChannels(array &$channels) : void {
1620
		$userId = $this->session->getUserId();
1621
		$allChannelsIncluded = (\count($channels) === $this->podcastChannelBusinessLayer->count($userId));
1622
		$this->podcastService->injectEpisodes($channels, $userId, $allChannelsIncluded);
1623
	}
1624
1625
	private function createAmpacheActionUrl(string $action, int $id, ?string $type=null) : string {
1626
		$api = $this->jsonMode ? 'music.ampache.jsonApi' : 'music.ampache.xmlApi';
1627
		$auth = $this->session->getToken();
1628
		return $this->urlGenerator->linkToRouteAbsolute($api)
1629
				. "?action=$action&id=$id&auth=$auth"
1630
				. (!empty($type) ? "&type=$type" : '');
1631
	}
1632
1633
	private function createCoverUrl(Entity $entity) : string {
1634
		if ($entity instanceof Album) {
1635
			$type = 'album';
1636
		} elseif ($entity instanceof Artist) {
1637
			$type = 'artist';
1638
		} elseif ($entity instanceof Playlist) {
1639
			$type = 'playlist';
1640
		} else {
1641
			throw new AmpacheException('unexpeted entity type for cover image', 500);
1642
		}
1643
1644
		// Scrutinizer doesn't understand that the if-else above guarantees that getCoverFileId() may be called only on Album or Artist
1645
		if ($type === 'playlist' || $entity->/** @scrutinizer ignore-call */getCoverFileId()) {
1646
			$id = $entity->getId();
1647
			$token = $this->imageService->getToken($type, $id, $this->session->getAmpacheUserId());
1648
			return $this->urlGenerator->linkToRouteAbsolute('music.ampacheImage.image') . "?object_type=$type&object_id=$id&token=$token";
1649
		} else {
1650
			return '';
1651
		}
1652
	}
1653
1654
	private static function indexIsWithinOffsetAndLimit(int $index, ?int $offset, ?int $limit) : bool {
1655
		$offset = \intval($offset); // missing offset is interpreted as 0-offset
1656
		return ($limit === null) || ($index >= $offset && $index < $offset + $limit);
1657
	}
1658
1659
	private function prefixAndBaseName(?string $name) : array {
1660
		$parts = ['prefix' => null, 'basename' => $name];
1661
1662
		if ($name !== null) {
1663
			foreach ($this->namePrefixes as $prefix) {
1664
				if (Util::startsWith($name, $prefix . ' ', /*ignoreCase=*/true)) {
1665
					$parts['prefix'] = $prefix;
1666
					$parts['basename'] = \substr($name, \strlen($prefix) + 1);
1667
					break;
1668
				}
1669
			}
1670
		}
1671
1672
		return $parts;
1673
	}
1674
1675
	private function renderAlbumOrArtistRef(int $id, string $name) : array {
1676
		if ($this->apiMajorVersion() > 5) {
1677
			return [
1678
				'id' => (string)$id,
1679
				'name' => $name,
1680
			] + $this->prefixAndBaseName($name);
1681
		} else {
1682
			return [
1683
				'id' => (string)$id,
1684
				'text' => $name
1685
			];
1686
		}
1687
	}
1688
1689
	/**
1690
	 * @param Artist[] $artists
1691
	 */
1692
	private function renderArtists(array $artists) : array {
1693
		$userId = $this->session->getUserId();
1694
		$genreMap = Util::createIdLookupTable($this->genreBusinessLayer->findAll($userId));
1695
		$genreKey = $this->genreKey();
1696
		// In APIv3-4, the properties 'albums' and 'songs' were used for the album/song count in case the inclusion of the relevan
1697
		// child objects wasn't requested. APIv5+ has the dedoicated properties 'albumcount' and 'songcount' for this purpose.
1698
		$oldCountApi = ($this->apiMajorVersion() < 5);
1699
1700
		return [
1701
			'artist' => \array_map(function (Artist $artist) use ($userId, $genreMap, $genreKey, $oldCountApi) {
1702
				$name = $artist->getNameString($this->l10n);
1703
				$nameParts = $this->prefixAndBaseName($name);
1704
				$albumCount = $this->albumBusinessLayer->countByAlbumArtist($artist->getId());
1705
				$songCount = $this->trackBusinessLayer->countByArtist($artist->getId());
1706
				$albums = $artist->getAlbums();
1707
				$songs = $artist->getTracks();
1708
1709
				$apiArtist = [
1710
					'id' => (string)$artist->getId(),
1711
					'name' => $name,
1712
					'prefix' => $nameParts['prefix'],
1713
					'basename' => $nameParts['basename'],
1714
					'albums' => ($albums !== null) ? $this->renderAlbums($albums) : ($oldCountApi ? $albumCount : null),
1715
					'albumcount' => $albumCount,
1716
					'songs' => ($songs !== null) ? $this->renderSongs($songs) : ($oldCountApi ? $songCount : null),
1717
					'songcount' => $songCount,
1718
					'time' => $this->trackBusinessLayer->totalDurationByArtist($artist->getId()),
1719
					'art' => $this->createCoverUrl($artist),
1720
					'rating' => $artist->getRating() ?? 0,
1721
					'preciserating' => $artist->getRating() ?? 0,
1722
					'flag' => !empty($artist->getStarred()),
1723
					$genreKey => \array_map(function ($genreId) use ($genreMap) {
1724
						return [
1725
							'id' => (string)$genreId,
1726
							'text' => $genreMap[$genreId]->getNameString($this->l10n),
1727
							'count' => 1
1728
						];
1729
					}, $this->trackBusinessLayer->getGenresByArtistId($artist->getId(), $userId))
1730
				];
1731
1732
				if ($this->jsonMode) {
1733
					// Remove an unnecessary level on the JSON API
1734
					if ($albums !== null) {
1735
						$apiArtist['albums'] = $apiArtist['albums']['album'];
1736
					}
1737
					if ($songs !== null) {
1738
						$apiArtist['songs'] = $apiArtist['songs']['song'];
1739
					}
1740
				}
1741
1742
				return $apiArtist;
1743
			}, $artists)
1744
		];
1745
	}
1746
1747
	/**
1748
	 * @param Album[] $albums
1749
	 */
1750
	private function renderAlbums(array $albums) : array {
1751
		$genreKey = $this->genreKey();
1752
		$apiMajor = $this->apiMajorVersion();
1753
		// In APIv6 JSON format, there is a new property `artists` with an array value
1754
		$includeArtists = ($this->jsonMode && $apiMajor > 5);
1755
		// In APIv3-4, the property 'tracks' was used for the song count in case the inclusion of songs wasn't requested.
1756
		// APIv5+ has the property 'songcount' for this and 'tracks' may only contain objects.
1757
		$tracksMayDenoteCount = ($apiMajor < 5);
1758
1759
		return [
1760
			'album' => \array_map(function (Album $album) use ($genreKey, $includeArtists, $tracksMayDenoteCount) {
1761
				$name = $album->getNameString($this->l10n);
1762
				$nameParts = $this->prefixAndBaseName($name);
1763
				$songCount = $this->trackBusinessLayer->countByAlbum($album->getId());
1764
				$songs = $album->getTracks();
1765
1766
				$apiAlbum = [
1767
					'id' => (string)$album->getId(),
1768
					'name' => $name,
1769
					'prefix' => $nameParts['prefix'],
1770
					'basename' => $nameParts['basename'],
1771
					'artist' => $this->renderAlbumOrArtistRef(
1772
						$album->getAlbumArtistId(),
1773
						$album->getAlbumArtistNameString($this->l10n)
1774
					),
1775
					'tracks' => ($songs !== null) ? $this->renderSongs($songs, false) : ($tracksMayDenoteCount ? $songCount : null),
1776
					'songcount' => $songCount,
1777
					'diskcount' => $album->getNumberOfDisks(),
1778
					'time' => $this->trackBusinessLayer->totalDurationOfAlbum($album->getId()),
1779
					'rating' => $album->getRating() ?? 0,
1780
					'preciserating' => $album->getRating() ?? 0,
1781
					'year' => $album->yearToAPI(),
1782
					'art' => $this->createCoverUrl($album),
1783
					'flag' => !empty($album->getStarred()),
1784
					$genreKey => \array_map(function ($genre) {
1785
						return [
1786
							'id' => (string)$genre->getId(),
1787
							'text' => $genre->getNameString($this->l10n),
1788
							'count' => 1
1789
						];
1790
					}, $album->getGenres() ?? [])
1791
				];
1792
				if ($includeArtists) {
1793
					$apiAlbum['artists'] = [$apiAlbum['artist']];
1794
				}
1795
				if ($this->jsonMode && $songs !== null) {
1796
					// Remove an unnecessary level on the JSON API
1797
					$apiAlbum['tracks'] = $apiAlbum['tracks']['song'];
1798
				}
1799
1800
				return $apiAlbum;
1801
			}, $albums)
1802
		];
1803
	}
1804
1805
	/**
1806
	 * @param Track[] $tracks
1807
	 */
1808
	private function renderSongs(array $tracks, bool $injectAlbums=true) : array {
1809
		if ($injectAlbums) {
1810
			$userId = $this->session->getUserId();
1811
			$this->albumBusinessLayer->injectAlbumsToTracks($tracks, $userId);
1812
		}
1813
1814
		$createPlayUrl = function(Track $track) : string {
1815
			return $this->createAmpacheActionUrl('download', $track->getId());
1816
		};
1817
		$createImageUrl = function(Track $track) : string {
1818
			$album = $track->getAlbum();
1819
			return ($album !== null) ? $this->createCoverUrl($album) : '';
1820
		};
1821
		$renderRef = function(int $id, string $name) : array {
1822
			return $this->renderAlbumOrArtistRef($id, $name);
1823
		};
1824
		$genreKey = $this->genreKey();
1825
		// In APIv6 JSON format, there is a new property `artists` with an array value
1826
		$includeArtists = ($this->jsonMode && $this->apiMajorVersion() > 5);
1827
1828
		return [
1829
			'song' => Util::arrayMapMethod($tracks, 'toAmpacheApi', 
1830
				[$this->l10n, $createPlayUrl, $createImageUrl, $renderRef, $genreKey, $includeArtists])
1831
		];
1832
	}
1833
1834
	/**
1835
	 * @param Playlist[] $playlists
1836
	 */
1837
	private function renderPlaylists(array $playlists) : array {
1838
		$createImageUrl = function(Playlist $playlist) : string {
1839
			if ($playlist->getId() === self::ALL_TRACKS_PLAYLIST_ID) {
1840
				return '';
1841
			} else {
1842
				return $this->createCoverUrl($playlist);
1843
			}
1844
		};
1845
1846
		return [
1847
			'playlist' => Util::arrayMapMethod($playlists, 'toAmpacheApi', [$createImageUrl])
1848
		];
1849
	}
1850
1851
	/**
1852
	 * @param PodcastChannel[] $channels
1853
	 */
1854
	private function renderPodcastChannels(array $channels) : array {
1855
		return [
1856
			'podcast' => Util::arrayMapMethod($channels, 'toAmpacheApi')
1857
		];
1858
	}
1859
1860
	/**
1861
	 * @param PodcastEpisode[] $episodes
1862
	 */
1863
	private function renderPodcastEpisodes(array $episodes) : array {
1864
		return [
1865
			'podcast_episode' => Util::arrayMapMethod($episodes, 'toAmpacheApi')
1866
		];
1867
	}
1868
1869
	/**
1870
	 * @param RadioStation[] $stations
1871
	 */
1872
	private function renderLiveStreams(array $stations) : array {
1873
		return [
1874
			'live_stream' => Util::arrayMapMethod($stations, 'toAmpacheApi')
1875
		];
1876
	}
1877
1878
	/**
1879
	 * @param Genre[] $genres
1880
	 */
1881
	private function renderTags(array $genres) : array {
1882
		return [
1883
			'tag' => Util::arrayMapMethod($genres, 'toAmpacheApi', [$this->l10n])
1884
		];
1885
	}
1886
1887
	/**
1888
	 * @param Genre[] $genres
1889
	 */
1890
	private function renderGenres(array $genres) : array {
1891
		return [
1892
			'genre' => Util::arrayMapMethod($genres, 'toAmpacheApi', [$this->l10n])
1893
		];
1894
	}
1895
1896
	/**
1897
	 * @param Bookmark[] $bookmarks
1898
	 */
1899
	private function renderBookmarks(array $bookmarks) : array {
1900
		return [
1901
			'bookmark' => Util::arrayMapMethod($bookmarks, 'toAmpacheApi')
1902
		];
1903
	}
1904
1905
	/**
1906
	 * @param Track[] $tracks
1907
	 */
1908
	private function renderSongsIndex(array $tracks) : array {
1909
		return [
1910
			'song' => \array_map(function ($track) {
1911
				return [
1912
					'id' => (string)$track->getId(),
1913
					'title' => $track->getTitle(),
1914
					'name' => $track->getTitle(),
1915
					'artist' => $this->renderAlbumOrArtistRef($track->getArtistId(), $track->getArtistNameString($this->l10n)),
1916
					'album' => $this->renderAlbumOrArtistRef($track->getAlbumId(), $track->getAlbumNameString($this->l10n))
1917
				];
1918
			}, $tracks)
1919
		];
1920
	}
1921
1922
	/**
1923
	 * @param Album[] $albums
1924
	 */
1925
	private function renderAlbumsIndex(array $albums) : array {
1926
		return [
1927
			'album' => \array_map(function ($album) {
1928
				$name = $album->getNameString($this->l10n);
1929
				$nameParts = $this->prefixAndBaseName($name);
1930
1931
				return [
1932
					'id' => (string)$album->getId(),
1933
					'name' => $name,
1934
					'prefix' => $nameParts['prefix'],
1935
					'basename' => $nameParts['basename'],
1936
					'artist' => $this->renderAlbumOrArtistRef($album->getAlbumArtistId(), $album->getAlbumArtistNameString($this->l10n))
1937
				];
1938
			}, $albums)
1939
		];
1940
	}
1941
1942
	/**
1943
	 * @param Artist[] $artists
1944
	 */
1945
	private function renderArtistsIndex(array $artists) : array {
1946
		return [
1947
			'artist' => \array_map(function ($artist) {
1948
				$userId = $this->session->getUserId();
1949
				$albums = $this->albumBusinessLayer->findAllByArtist($artist->getId(), $userId);
1950
				$name = $artist->getNameString($this->l10n);
1951
				$nameParts = $this->prefixAndBaseName($name);
1952
1953
				return [
1954
					'id' => (string)$artist->getId(),
1955
					'name' => $name,
1956
					'prefix' => $nameParts['prefix'],
1957
					'basename' => $nameParts['basename'],
1958
					'album' => \array_map(function ($album) {
1959
						return $this->renderAlbumOrArtistRef($album->getId(), $album->getNameString($this->l10n));
1960
					}, $albums)
1961
				];
1962
			}, $artists)
1963
		];
1964
	}
1965
1966
	/**
1967
	 * @param Playlist[] $playlists
1968
	 */
1969
	private function renderPlaylistsIndex(array $playlists) : array {
1970
		return [
1971
			'playlist' => \array_map(function ($playlist) {
1972
				return [
1973
					'id' => (string)$playlist->getId(),
1974
					'name' => $playlist->getName(),
1975
					'playlisttrack' => $playlist->getTrackIdsAsArray()
1976
				];
1977
			}, $playlists)
1978
		];
1979
	}
1980
1981
	/**
1982
	 * @param PodcastChannel[] $channels
1983
	 */
1984
	private function renderPodcastChannelsIndex(array $channels) : array {
1985
		// The v4 API spec does not give any examples of this, and the v5 example is almost identical to the v4 "normal" result
1986
		return $this->renderPodcastChannels($channels);
1987
	}
1988
1989
	/**
1990
	 * @param PodcastEpisode[] $episodes
1991
	 */
1992
	private function renderPodcastEpisodesIndex(array $episodes) : array {
1993
		// The v4 API spec does not give any examples of this, and the v5 example is almost identical to the v4 "normal" result
1994
		return $this->renderPodcastEpisodes($episodes);
1995
	}
1996
1997
	/**
1998
	 * @param RadioStation[] $stations
1999
	 */
2000
	private function renderLiveStreamsIndex(array $stations) : array {
2001
		// The API spec gives no examples of this, but testing with Ampache demo server revealed that the format is indentical to the "full" format
2002
		return $this->renderLiveStreams($stations);
2003
	}
2004
2005
	/**
2006
	 * @param Entity[] $entities
2007
	 */
2008
	private function renderEntityIds(array $entities) : array {
2009
		return ['id' => Util::extractIds($entities)];
2010
	}
2011
2012
	/**
2013
	 * Array is considered to be "indexed" if its first element has numerical key.
2014
	 * Empty array is considered to be "indexed".
2015
	 */
2016
	private static function arrayIsIndexed(array $array) : bool {
2017
		\reset($array);
2018
		return empty($array) || \is_int(\key($array));
2019
	}
2020
2021
	/**
2022
	 * The JSON API has some asymmetries with the XML API. This function makes the needed
2023
	 * translations for the result content before it is converted into JSON.
2024
	 */
2025
	private function prepareResultForJsonApi(array $content) : array {
2026
		$apiVer = $this->apiMajorVersion();
2027
2028
		// Special handling is needed for responses returning an array of library entities,
2029
		// depending on the API version. In these cases, the outermost array is of associative
2030
		// type with a single value which is a non-associative array.
2031
		if (\count($content) === 1 && !self::arrayIsIndexed($content)
2032
				&& \is_array(\current($content)) && self::arrayIsIndexed(\current($content))) {
2033
			// In API versions < 5, the root node is an anonymous array. Unwrap the outermost array.
2034
			if ($apiVer < 5) {
2035
				$content = \array_pop($content);
2036
			}
2037
			// In later versions, the root object has a named array for plural actions (like "songs", "artists").
2038
			// For singular actions (like "song", "artist"), the root object contains directly the entity properties.
2039
			else {
2040
				$action = $this->request->getParam('action');
2041
				$plural = (\substr($action, -1) === 's' || \in_array($action, ['get_similar', 'advanced_search', 'list']));
2042
2043
				// In APIv5, the action "album" is an excption, it is formatted as if it was a plural action.
2044
				// This outlier has been fixed in APIv6.
2045
				$api5albumOddity = ($apiVer === 5 && $action === 'album');
2046
2047
				// The actions "user_preference" and "system_preference" are another kind of outliers in APIv5,
2048
				// their reponses are anonymou 1-item arrays. This got fixed in the APIv6.0.1
2049
				$api5preferenceOddity = ($apiVer === 5 && Util::endsWith($action, 'preference'));
2050
2051
				if ($api5preferenceOddity) {
2052
					$content = \array_pop($content);
2053
				} elseif (!($plural  || $api5albumOddity)) {
2054
					$content = \array_pop($content);
2055
					$content = \array_pop($content);
2056
				}
2057
			}
2058
		}
2059
2060
		// In API versions < 6, all boolean valued properties should be converted to 0/1.
2061
		if ($apiVer < 6) {
2062
			Util::intCastArrayValues($content, 'is_bool');
2063
		}
2064
2065
		// The key 'text' has a special meaning on XML responses, as it makes the corresponding value
2066
		// to be treated as text content of the parent element. In the JSON API, these are mostly
2067
		// substituted with property 'name', but error responses use the property 'message', instead.
2068
		if (\array_key_exists('error', $content)) {
2069
			$content = Util::convertArrayKeys($content, ['text' => 'message']);
2070
		} else {
2071
			$content = Util::convertArrayKeys($content, ['text' => 'name']);
2072
		}
2073
		return $content;
2074
	}
2075
2076
	/**
2077
	 * The XML API has some asymmetries with the JSON API. This function makes the needed
2078
	 * translations for the result content before it is converted into XML.
2079
	 */
2080
	private function prepareResultForXmlApi(array $content) : array {
2081
		\reset($content);
2082
		$firstKey = \key($content);
2083
2084
		// all 'entity list' kind of responses shall have the (deprecated) total_count element
2085
		if ($firstKey == 'song' || $firstKey == 'album' || $firstKey == 'artist' || $firstKey == 'playlist'
2086
				|| $firstKey == 'tag' || $firstKey == 'genre' || $firstKey == 'podcast' || $firstKey == 'podcast_episode'
2087
				|| $firstKey == 'live_stream') {
2088
			$content = ['total_count' => \count($content[$firstKey])] + $content;
2089
		}
2090
2091
		// for some bizarre reason, the 'id' arrays have 'index' attributes in the XML format
2092
		if ($firstKey == 'id') {
2093
			$content['id'] = \array_map(function ($id, $index) {
2094
				return ['index' => $index, 'text' => $id];
2095
			}, $content['id'], \array_keys($content['id']));
2096
		}
2097
2098
		return ['root' => $content];
2099
	}
2100
2101
	private function genreKey() : string {
2102
		return ($this->apiMajorVersion() > 4) ? 'genre' : 'tag';
2103
	}
2104
2105
	private function apiMajorVersion() : int {
2106
		// During the handshake, we don't yet have a session but the requeted version may be in the request args
2107
		$verString = ($this->session !== null) 
2108
			? $this->session->getApiVersion()
2109
			: $this->request->getParam('version');
2110
		
2111
		if (\is_string($verString) && \strlen($verString)) {
2112
			$ver = (int)$verString[0];
2113
		} else {
2114
			// Default version is 6 unless otherwise defined in config.php
2115
			$ver = (int)$this->config->getSystemValue('music.ampache_api_default_ver', 6);
2116
		}
2117
2118
		// For now, we have three supported major versions. Major version 3 can be sufficiently supported
2119
		// with our "version 4" implementation.
2120
		return (int)Util::limit($ver, 4, 6);
2121
	}
2122
2123
	private function apiVersionString() : string {
2124
		switch ($this->apiMajorVersion()) {
2125
			case 4:		return self::API4_VERSION;
2126
			case 5:		return self::API5_VERSION;
2127
			case 6:		return self::API6_VERSION;
2128
			default:	throw new AmpacheException('Unexpected api major version', 500);
2129
		}
2130
	}
2131
2132
	private function mapApiV4ErrorToV5(int $code) : int {
2133
		switch ($code) {
2134
			case 400:	return 4710;	// bad request
2135
			case 401:	return 4701;	// invalid handshake
2136
			case 403:	return 4703;	// access denied
2137
			case 404:	return 4704;	// not found
2138
			case 405:	return 4705;	// missing
2139
			case 412:	return 4742;	// failed access check
2140
			case 501:	return 4700;	// access control not enabled
2141
			default:	return 5000;	// unexcpected (not part of the API spec)
2142
		}
2143
	}
2144
}
2145
2146
/**
2147
 * Adapter class which acts like the Playlist class for the purpose of
2148
 * AmpacheController::renderPlaylists but contains all the track of the user.
2149
 */
2150
class AmpacheController_AllTracksPlaylist extends Playlist {
2151
	private $trackBusinessLayer;
2152
	private $l10n;
2153
2154
	public function __construct(string $userId, TrackBusinessLayer $trackBusinessLayer, IL10N $l10n) {
2155
		$this->userId = $userId;
2156
		$this->trackBusinessLayer = $trackBusinessLayer;
2157
		$this->l10n = $l10n;
2158
	}
2159
2160
	public function getId() : int {
2161
		return AmpacheController::ALL_TRACKS_PLAYLIST_ID;
2162
	}
2163
2164
	public function getName() : string {
2165
		return $this->l10n->t('All tracks');
2166
	}
2167
2168
	public function getTrackCount() : int {
2169
		return $this->trackBusinessLayer->count($this->userId);
2170
	}
2171
}
2172