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