Passed
Pull Request — master (#1078)
by Pauli
05:23 queued 02:42
created

AmpacheController::artists()   B

Complexity

Conditions 7
Paths 9

Size

Total Lines 24
Code Lines 14

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 7
eloc 14
c 1
b 0
f 0
nc 9
nop 8
dl 0
loc 24
rs 8.8333

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