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

AmpacheController   F

Complexity

Total Complexity 384

Size/Duplication

Total Lines 2061
Duplicated Lines 0 %

Importance

Changes 21
Bugs 0 Features 0
Metric Value
eloc 1030
dl 0
loc 2061
rs 1.48
c 21
b 0
f 0
wmc 384

114 Methods

Rating   Name   Duplication   Size   Complexity  
A ampacheResponse() 0 5 2
A goodbye() 0 2 1
A setSession() 0 3 1
A setJsonMode() 0 2 1
A handshake() 0 29 1
B dispatch() 0 37 6
A jsonApi() 0 3 1
A __construct() 0 44 1
A xmlApi() 0 3 1
A ping() 0 13 2
A ampacheErrorResponse() 0 22 2
A podcast_episodes() 0 4 1
A genreKey() 0 2 2
A tag_artists() 0 4 1
A renderPodcastEpisodes() 0 3 1
A artist_albums() 0 4 1
A renderPodcastChannelsIndex() 0 3 1
A album_songs() 0 4 1
A user_preference() 0 6 2
B renderAlbums() 0 52 7
A genre_songs() 0 5 2
B stats() 0 54 10
A renderBookmarks() 0 3 1
A renderLiveStreamsIndex() 0 3 1
A renderPlaylistsIndex() 0 9 1
A tag() 0 4 1
A renderLiveStreams() 0 3 1
A artists() 0 23 5
B advanced_search() 0 36 6
A stream() 0 15 2
A playlists() 0 15 6
A renderPlaylists() 0 11 2
A injectEpisodesToChannels() 0 4 1
A list() 0 16 2
B getBusinessLayer() 0 13 11
A prefixAndBaseName() 0 14 4
B playlist_remove_song() 0 27 7
A get_bookmark() 0 4 1
B advSearchGetRuleParams() 0 28 10
B prepareResultForXmlApi() 0 19 11
A album() 0 9 2
A artist() 0 13 3
A findEntities() 0 12 3
A renderEntityIds() 0 2 1
A podcast() 0 9 2
A apiMajorVersion() 0 16 4
A renderArtistsIndex() 0 18 1
A get_indexes() 0 19 4
B renderArtists() 0 52 8
A live_stream_delete() 0 3 1
A update_podcast() 0 16 6
A renderPodcastEpisodesIndex() 0 3 1
A live_streams() 0 3 1
A playlist() 0 8 2
A song() 0 5 1
A flag() 0 19 4
A renderTags() 0 3 1
A mapBookmarkType() 0 5 3
A apiVersionString() 0 6 4
A renderSongsIndex() 0 11 1
C prepareResultForJsonApi() 0 49 14
A advSearchConvertInput() 0 11 3
A genres() 0 3 1
A playlist_delete() 0 3 1
A getCover() 0 15 3
A tag_songs() 0 4 1
A podcast_episode() 0 4 1
A get_art() 0 12 3
B playlist_generate() 0 42 8
B renderEntities() 0 13 11
A live_stream() 0 3 1
A createCoverUrl() 0 18 6
B playlist_edit() 0 35 7
A genre_albums() 0 5 2
A playlist_songs() 0 11 3
A genre() 0 4 1
A albums() 0 11 2
B renderEntitiesIndex() 0 13 11
A renderAlbumsIndex() 0 14 1
B download() 0 33 9
A renderPodcastChannels() 0 3 1
A bookmark_delete() 0 5 1
A arrayIsIndexed() 0 3 2
A live_stream_create() 0 3 1
A songs() 0 6 1
A rate() 0 14 2
A parseTimeParameters() 0 11 1
A tags() 0 3 1
A bookmarks() 0 3 1
A indexIsWithinOffsetAndLimit() 0 3 3
A renderGenres() 0 3 1
A bookmark_create() 0 6 1
A live_stream_edit() 0 15 4
A podcast_delete() 0 11 3
D advSearchInterpretOperator() 0 76 29
A get_similar() 0 11 3
A record_play() 0 4 2
A bookmark_edit() 0 10 2
A podcast_create() 0 15 5
A podcasts() 0 8 2
A createAmpacheActionUrl() 0 6 3
A playlist_create() 0 3 1
A search_songs() 0 4 1
A renderAlbumOrArtistRef() 0 10 2
A getAppNameAndVersion() 0 7 1
A genre_artists() 0 5 2
A artist_songs() 0 9 2
A tag_albums() 0 4 1
A playlist_add_song() 0 17 4
C advSearchResolveRuleAlias() 0 16 14
B browse() 0 53 11
A user_preferences() 0 2 1
A renderSongs() 0 23 4
B mapApiV4ErrorToV5() 0 10 8

How to fix   Complexity   

Complex Class

Complex classes like AmpacheController often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use AmpacheController, and based on these observations, apply Extract Interface, too.

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