Passed
Push — master ( 022c2a...9c43c9 )
by Pauli
02:58
created

AmpacheController   F

Complexity

Total Complexity 113

Size/Duplication

Total Lines 2416
Duplicated Lines 0 %

Importance

Changes 37
Bugs 0 Features 0
Metric Value
dl 0
loc 2416
rs 0.8
c 37
b 0
f 0
eloc 1206
wmc 113

171 Methods

Rating   Name   Duplication   Size   Complexity  
A ampacheResponse() 0 5 2
A setSession() 0 3 1
A setJsonMode() 0 2 1
B dispatch() 0 37 6
A jsonApi() 0 3 1
A __construct() 0 46 1
A xmlApi() 0 3 1
A ampacheErrorResponse() 0 22 2
A podcast_episodes() 0 4 1
genreKey() 0 2 ?
A tag_artists() 0 4 1
renderPodcastEpisodes() 0 7 ?
A artist_albums() 0 4 1
renderPodcastChannelsIndex() 0 3 ?
A album_songs() 0 4 1
renderAlbums() 0 53 ?
A user_preference() 0 6 2
A genre_songs() 0 5 2
B stats() 0 54 10
renderBookmarks() 0 13 ?
renderLiveStreamsIndex() 0 3 ?
renderPlaylistsIndex() 0 9 ?
A hp$0 ➔ prepareResultForXmlApi() 0 18 3
A tag() 0 4 1
B hp$0 ➔ createCoverUrl() 0 23 7
renderLiveStreams() 0 7 ?
A artists() 0 23 5
A advanced_search() 0 29 6
A goodbye() 0 2 1
A folders() 0 20 1
A stream() 0 15 2
A playlists() 0 15 6
B hp$0 ➔ renderArtists() 0 53 8
A hp$0 ➔ parseTimeParameters() 0 11 1
A system_preference() 0 2 1
renderPlaylists() 0 21 ?
injectEpisodesToChannels() 0 4 ?
A list() 0 16 2
A handshake() 0 36 1
B getBusinessLayer() 0 13 11
A hp$0 ➔ renderTags() 0 3 1
prefixAndBaseName() 0 2 ?
A hp$0 ➔ renderLiveStreams() 0 7 1
B playlist_remove_song() 0 27 7
A hp$0 ➔ renderGenres() 0 3 1
A get_bookmark() 0 4 1
prepareResultForXmlApi() 0 18 ?
B advSearchGetRuleParams() 0 28 10
A hp$0 ➔ getCover() 0 15 3
A hp$0 ➔ renderPodcastEpisodesIndex() 0 3 1
A hp$0 ➔ renderArtistsIndex() 0 18 1
A album() 0 9 2
A artist() 0 13 3
A hp$0 ➔ indexIsWithinOffsetAndLimit() 0 3 3
A hp$0 ➔ renderAlbumOrArtistRef() 0 10 2
getAllTracksPlaylist() 0 16 ?
findEntities() 0 12 ?
B getChildEntityType() 0 9 7
renderEntityIds() 0 2 ?
A trackIdsForEntity() 0 13 5
A podcast() 0 9 2
renderArtistsIndex() 0 18 ?
A get_indexes() 0 24 6
A playlist_hash() 0 8 2
renderArtists() 0 53 ?
A bookmark() 0 3 1
A live_stream_delete() 0 3 1
renderPodcastEpisodesIndex() 0 3 ?
A update_podcast() 0 16 6
A live_streams() 0 3 1
A hp$0 ➔ createAmpacheActionUrl() 0 6 3
A playlist() 0 8 2
A song() 0 5 1
C hp$0 ➔ prepareResultForJsonApi() 0 49 14
A flag() 0 19 4
A hp$0 ➔ renderSongs() 0 23 5
renderTags() 0 3 ?
A hp$0 ➔ renderBookmarks() 0 13 2
A mapBookmarkType() 0 5 3
renderSongsIndex() 0 11 ?
prepareResultForJsonApi() 0 49 ?
A hp$0 ➔ prefixAndBaseName() 0 2 1
A hp$0 ➔ renderPlaylistsIndex() 0 9 1
renderIdsWithChildren() 0 18 ?
A user() 0 24 3
A user_playlists() 0 4 1
A advSearchConvertInput() 0 11 3
A genres() 0 3 1
A playlist_delete() 0 3 1
getCover() 0 15 ?
A tag_songs() 0 4 1
A hp$0 ➔ getAllTracksPlaylist() 0 16 1
A hp$0 ➔ renderPodcastChannels() 0 3 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 hp$0 ➔ renderEntityIds() 0 2 1
A live_stream() 0 3 1
A hp$0 ➔ renderPodcastChannelsIndex() 0 3 1
A ping() 0 13 2
A search() 0 3 1
createCoverUrl() 0 23 ?
B playlist_edit() 0 35 7
B index() 0 37 9
A hp$0 ➔ renderEntityIdIndex() 0 10 2
A genre_albums() 0 5 2
A playlist_songs() 0 27 5
A genre() 0 4 1
A albums() 0 11 2
B renderEntitiesIndex() 0 13 11
renderAlbumsIndex() 0 14 ?
A hp$0 ➔ injectEpisodesToChannels() 0 4 1
A folder_songs() 0 4 1
renderPodcastChannels() 0 3 ?
B download() 0 36 9
A hp$0 ➔ renderSongsIndex() 0 11 1
A bookmark_delete() 0 5 1
A hp$0 ➔ renderIdsWithChildren() 0 18 3
arrayIsIndexed() 0 3 ?
B hp$0 ➔ renderAlbums() 0 53 7
A live_stream_create() 0 3 1
A hp$0 ➔ genreKey() 0 2 2
parseTimeParameters() 0 11 ?
A songs() 0 6 1
A rate() 0 14 2
A hp$0 ➔ findEntities() 0 12 3
A tags() 0 3 1
A system_preferences() 0 2 1
A user_smartlists() 0 4 1
A bookmarks() 0 3 1
renderGenres() 0 3 ?
indexIsWithinOffsetAndLimit() 0 3 ?
A bookmark_create() 0 6 1
A live_stream_edit() 0 15 4
A scrobble() 0 9 3
A podcast_delete() 0 11 3
A hp$0 ➔ renderLiveStreamsIndex() 0 3 1
A playlist_add() 0 16 2
D advSearchInterpretOperator() 0 76 29
A hp$0 ➔ arrayIsIndexed() 0 3 2
renderEntityIdIndex() 0 10 ?
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
createAmpacheActionUrl() 0 6 ?
A playlist_create() 0 3 1
A search_songs() 0 4 1
renderAlbumOrArtistRef() 0 10 ?
A genre_artists() 0 5 2
A hp$0 ➔ renderAlbumsIndex() 0 14 1
A artist_songs() 0 9 2
A hp$0 ➔ getTrackCount() 0 2 1
A tag_albums() 0 4 1
A hp$0 ➔ renderPlaylists() 0 21 5
A hp$0 ➔ renderPodcastEpisodes() 0 7 1
A playlist_add_song() 0 17 4
C advSearchResolveRuleAlias() 0 16 14
B browse() 0 57 10
A user_preferences() 0 2 1
renderSongs() 0 23 ?
A hp$0 ➔ apiMajorVersion() 0 13 3
requestedApiVersion() 0 5 ?
A hp$0 ➔ requestedApiVersion() 0 5 2
apiMajorVersion() 0 13 ?
apiVersionString() 0 19 ?
B hp$0 ➔ apiVersionString() 0 19 8
B hp$0 ➔ mapApiV4ErrorToV5() 0 10 8
mapApiV4ErrorToV5() 0 10 ?

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