Passed
Push — feature/909_Ampache_API_improv... ( b752cc...8b1a0c )
by Pauli
02:57
created

AmpacheController::artists()   A

Complexity

Conditions 5
Paths 12

Size

Total Lines 23
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 14
nc 12
nop 8
dl 0
loc 23
rs 9.4888
c 1
b 0
f 0

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