AmpacheController   F
last analyzed

Complexity

Total Complexity 118

Size/Duplication

Total Lines 2433
Duplicated Lines 0 %

Importance

Changes 21
Bugs 0 Features 0
Metric Value
eloc 1198
dl 0
loc 2433
rs 0.808
c 21
b 0
f 0
wmc 118

175 Methods

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