Passed
Push — feature/909_Ampache_API_improv... ( f93b1c...7380bc )
by Pauli
02:41
created

AmpacheController::advanced_search()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 19
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 11
c 0
b 0
f 0
nc 2
nop 8
dl 0
loc 19
rs 9.9

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

1
<?php declare(strict_types=1);
2
3
/**
4
 * ownCloud - Music app
5
 *
6
 * This file is licensed under the Affero General Public License version 3 or
7
 * later. See the COPYING file.
8
 *
9
 * @author Morris Jobke <[email protected]>
10
 * @author Pauli Järvinen <[email protected]>
11
 * @copyright Morris Jobke 2013, 2014
12
 * @copyright Pauli Järvinen 2017 - 2023
13
 */
14
15
namespace OCA\Music\Controller;
16
17
use OCP\AppFramework\Controller;
18
use OCP\AppFramework\Http;
19
use OCP\AppFramework\Http\JSONResponse;
20
use OCP\AppFramework\Http\RedirectResponse;
21
use OCP\AppFramework\Http\Response;
22
use OCP\IConfig;
23
use OCP\IL10N;
24
use OCP\IRequest;
25
use OCP\IURLGenerator;
26
27
use OCA\Music\AppFramework\BusinessLayer\BusinessLayer;
28
use OCA\Music\AppFramework\BusinessLayer\BusinessLayerException;
29
use OCA\Music\AppFramework\Core\Logger;
30
use OCA\Music\AppFramework\Utility\MethodAnnotationReader;
31
use OCA\Music\AppFramework\Utility\RequestParameterExtractor;
32
use OCA\Music\AppFramework\Utility\RequestParameterExtractorException;
33
34
use OCA\Music\BusinessLayer\AlbumBusinessLayer;
35
use OCA\Music\BusinessLayer\ArtistBusinessLayer;
36
use OCA\Music\BusinessLayer\BookmarkBusinessLayer;
37
use OCA\Music\BusinessLayer\GenreBusinessLayer;
38
use OCA\Music\BusinessLayer\Library;
39
use OCA\Music\BusinessLayer\PlaylistBusinessLayer;
40
use OCA\Music\BusinessLayer\PodcastChannelBusinessLayer;
41
use OCA\Music\BusinessLayer\PodcastEpisodeBusinessLayer;
42
use OCA\Music\BusinessLayer\RadioStationBusinessLayer;
43
use OCA\Music\BusinessLayer\TrackBusinessLayer;
44
45
use OCA\Music\Db\Album;
46
use OCA\Music\Db\AmpacheSession;
47
use OCA\Music\Db\Artist;
48
use OCA\Music\Db\BaseMapper;
49
use OCA\Music\Db\Bookmark;
50
use OCA\Music\Db\Entity;
51
use OCA\Music\Db\Genre;
52
use OCA\Music\Db\RadioStation;
53
use OCA\Music\Db\MatchMode;
54
use OCA\Music\Db\Playlist;
55
use OCA\Music\Db\PodcastChannel;
56
use OCA\Music\Db\PodcastEpisode;
57
use OCA\Music\Db\SortBy;
58
use OCA\Music\Db\Track;
59
60
use OCA\Music\Http\ErrorResponse;
61
use OCA\Music\Http\FileResponse;
62
use OCA\Music\Http\FileStreamResponse;
63
use OCA\Music\Http\XmlResponse;
64
65
use OCA\Music\Middleware\AmpacheException;
66
67
use OCA\Music\Utility\AmpacheImageService;
68
use OCA\Music\Utility\AppInfo;
69
use OCA\Music\Utility\CoverHelper;
70
use OCA\Music\Utility\LastfmService;
71
use OCA\Music\Utility\LibrarySettings;
72
use OCA\Music\Utility\PodcastService;
73
use OCA\Music\Utility\Random;
74
use OCA\Music\Utility\Util;
75
76
class AmpacheController extends Controller {
77
	private $config;
78
	private $l10n;
79
	private $urlGenerator;
80
	private $albumBusinessLayer;
81
	private $artistBusinessLayer;
82
	private $bookmarkBusinessLayer;
83
	private $genreBusinessLayer;
84
	private $playlistBusinessLayer;
85
	private $podcastChannelBusinessLayer;
86
	private $podcastEpisodeBusinessLayer;
87
	private $radioStationBusinessLayer;
88
	private $trackBusinessLayer;
89
	private $library;
90
	private $podcastService;
91
	private $imageService;
92
	private $coverHelper;
93
	private $lastfmService;
94
	private $librarySettings;
95
	private $random;
96
	private $logger;
97
98
	private $jsonMode;
99
	private $session;
100
	private $namePrefixes;
101
102
	const ALL_TRACKS_PLAYLIST_ID = -1;
103
	const API4_VERSION = '440000';
104
	const API5_VERSION = '560000';
105
	const API6_VERSION = '600000';
106
	const API_MIN_COMPATIBLE_VERSION = '350001';
107
108
	public function __construct(string $appname,
109
								IRequest $request,
110
								IConfig $config,
111
								IL10N $l10n,
112
								IURLGenerator $urlGenerator,
113
								AlbumBusinessLayer $albumBusinessLayer,
114
								ArtistBusinessLayer $artistBusinessLayer,
115
								BookmarkBusinessLayer $bookmarkBusinessLayer,
116
								GenreBusinessLayer $genreBusinessLayer,
117
								PlaylistBusinessLayer $playlistBusinessLayer,
118
								PodcastChannelBusinessLayer $podcastChannelBusinessLayer,
119
								PodcastEpisodeBusinessLayer $podcastEpisodeBusinessLayer,
120
								RadioStationBusinessLayer $radioStationBusinessLayer,
121
								TrackBusinessLayer $trackBusinessLayer,
122
								Library $library,
123
								PodcastService $podcastService,
124
								AmpacheImageService $imageService,
125
								CoverHelper $coverHelper,
126
								LastfmService $lastfmService,
127
								LibrarySettings $librarySettings,
128
								Random $random,
129
								Logger $logger) {
130
		parent::__construct($appname, $request);
131
132
		$this->config = $config;
133
		$this->l10n = $l10n;
134
		$this->urlGenerator = $urlGenerator;
135
		$this->albumBusinessLayer = $albumBusinessLayer;
136
		$this->artistBusinessLayer = $artistBusinessLayer;
137
		$this->bookmarkBusinessLayer = $bookmarkBusinessLayer;
138
		$this->genreBusinessLayer = $genreBusinessLayer;
139
		$this->playlistBusinessLayer = $playlistBusinessLayer;
140
		$this->podcastChannelBusinessLayer = $podcastChannelBusinessLayer;
141
		$this->podcastEpisodeBusinessLayer = $podcastEpisodeBusinessLayer;
142
		$this->radioStationBusinessLayer = $radioStationBusinessLayer;
143
		$this->trackBusinessLayer = $trackBusinessLayer;
144
		$this->library = $library;
145
		$this->podcastService = $podcastService;
146
		$this->imageService = $imageService;
147
		$this->coverHelper = $coverHelper;
148
		$this->lastfmService = $lastfmService;
149
		$this->librarySettings = $librarySettings;
150
		$this->random = $random;
151
		$this->logger = $logger;
152
	}
153
154
	public function setJsonMode(bool $useJsonMode) : void {
155
		$this->jsonMode = $useJsonMode;
156
	}
157
158
	public function setSession(AmpacheSession $session) : void {
159
		$this->session = $session;
160
		$this->namePrefixes = $this->librarySettings->getIgnoredArticles($session->getUserId());
161
	}
162
163
	public function ampacheResponse(array $content) : Response {
164
		if ($this->jsonMode) {
165
			return new JSONResponse($this->prepareResultForJsonApi($content));
166
		} else {
167
			return new XmlResponse($this->prepareResultForXmlApi($content), ['id', 'index', 'count', 'code', 'errorCode'], true, true);
168
		}
169
	}
170
171
	public function ampacheErrorResponse(int $code, string $message) : Response {
172
		$this->logger->log($message, 'debug');
173
174
		if ($this->apiMajorVersion() > 4) {
175
			$code = $this->mapApiV4ErrorToV5($code);
176
			$content = [
177
				'error' => [
178
					'errorCode' => (string)$code,
179
					'errorAction' => $this->request->getParam('action'),
180
					'errorType' => 'system',
181
					'errorMessage' => $message
182
				]
183
			];
184
		} else {
185
			$content = [
186
				'error' => [
187
					'code' => (string)$code,
188
					'value' => $message
189
				]
190
			];
191
		}
192
		return $this->ampacheResponse($content);
193
	}
194
195
	/**
196
	 * @NoAdminRequired
197
	 * @PublicPage
198
	 * @NoCSRFRequired
199
	 * @NoSameSiteCookieRequired
200
	 */
201
	public function xmlApi(string $action) : Response {
202
		// differentation between xmlApi and jsonApi is made already by the middleware
203
		return $this->dispatch($action);
204
	}
205
206
	/**
207
	 * @NoAdminRequired
208
	 * @PublicPage
209
	 * @NoCSRFRequired
210
	 * @NoSameSiteCookieRequired
211
	 */
212
	public function jsonApi(string $action) : Response {
213
		// differentation between xmlApi and jsonApi is made already by the middleware
214
		return $this->dispatch($action);
215
	}
216
217
	protected function dispatch(string $action) : Response {
218
		$this->logger->log("Ampache action '$action' requested", 'debug');
219
220
		// Allow calling any functions annotated to be part of the API
221
		if (\method_exists($this, $action)) {
222
			$annotationReader = new MethodAnnotationReader($this, $action);
223
			if ($annotationReader->hasAnnotation('AmpacheAPI')) {
224
				// custom "filter" which modifies the value of the request argument `limit`
225
				$limitFilter = function(?string $value) : int {
226
					// Any non-integer values and integer value 0 are interpreted as "no limit".
227
					// On the other hand, the API spec mandates limiting responses to 5000 entries
228
					// even if no limit or larger limit has been passed.
229
					$value = (int)$value;
230
					if ($value <= 0) {
231
						$value = 5000;
232
					}
233
					return \min($value, 5000);
234
				};
235
236
				$parameterExtractor = new RequestParameterExtractor($this->request, ['limit' => $limitFilter]);
237
				try {
238
					$parameterValues = $parameterExtractor->getParametersForMethod($this, $action);
239
				} catch (RequestParameterExtractorException $ex) {
240
					throw new AmpacheException($ex->getMessage(), 400);
241
				}
242
				$response = \call_user_func_array([$this, $action], $parameterValues);
243
				// The API methods may return either a Response object or an array, which should be converted to Response
244
				if (!($response instanceof Response)) {
245
					$response = $this->ampacheResponse($response);
246
				}
247
				return $response;
248
			}
249
		}
250
251
		// No method was found for this action
252
		$this->logger->log("Unsupported Ampache action '$action' requested", 'warn');
253
		throw new AmpacheException('Action not supported', 405);
254
	}
255
256
	/***********************
257
	 * Ampahce API methods *
258
	 ***********************/
259
260
	/**
261
	 * Get the handshake result. The actual user authentication and session creation logic has happened prior to calling
262
	 * this in the class AmpacheMiddleware.
263
	 * 
264
	 * @AmpacheAPI
265
	 */
266
	 protected function handshake() : array {
267
		$user = $this->session->getUserId();
268
		$updateTime = \max($this->library->latestUpdateTime($user), $this->playlistBusinessLayer->latestUpdateTime($user));
269
		$addTime = \max($this->library->latestInsertTime($user), $this->playlistBusinessLayer->latestInsertTime($user));
270
		$genresKey = $this->genreKey() . 's';
271
		$playlistCount = $this->playlistBusinessLayer->count($user);
272
		
273
		return [
274
			'session_expire' => \date('c', $this->session->getExpiry()),
275
			'auth' => $this->session->getToken(),
276
			'api' => $this->apiVersionString(),
277
			'update' => $updateTime->format('c'),
278
			'add' => $addTime->format('c'),
279
			'clean' => \date('c', \time()), // TODO: actual time of the latest item removal
280
			'songs' => $this->trackBusinessLayer->count($user),
281
			'artists' => $this->artistBusinessLayer->count($user),
282
			'albums' => $this->albumBusinessLayer->count($user),
283
			'playlists' => $playlistCount,
284
			'searches' => 1, // "All tracks"
285
			'playlists_searches' => $playlistCount + 1,
286
			'podcasts' => $this->podcastChannelBusinessLayer->count($user),
287
			'podcast_episodes' => $this->podcastEpisodeBusinessLayer->count($user),
288
			'live_streams' => $this->radioStationBusinessLayer->count($user),
289
			$genresKey => $this->genreBusinessLayer->count($user),
290
			'videos' => 0,
291
			'catalogs' => 0,
292
			'shares' => 0,
293
			'licenses' => 0,
294
			'labels' => 0
295
		];
296
	}
297
298
	/**
299
	 * Get the result for the 'goodbye' command. The actual logout is handled by AmpacheMiddleware.
300
	 * 
301
	 * @AmpacheAPI
302
	 */
303
	protected function goodbye() : array {
304
		return ['success' => "goodbye: {$this->session->getToken()}"];
305
	}
306
307
	/**
308
	 * @AmpacheAPI
309
	 */
310
	protected function ping() : array {
311
		$response = [
312
			'server' => $this->getAppNameAndVersion(),
313
			'version' => self::API6_VERSION,
314
			'compatible' => self::API_MIN_COMPATIBLE_VERSION
315
		];
316
317
		if ($this->session) {
318
			// in case ping is called within a valid session, the response will contain also the "handshake fields"
319
			$response += $this->handshake();
320
		}
321
322
		return $response;
323
	}
324
325
	/**
326
	 * @AmpacheAPI
327
	 */
328
	protected function get_indexes(string $type, ?string $filter, ?string $add, ?string $update, int $limit, int $offset=0) : array {
329
		if ($type === 'album_artist') {
330
			if (!empty($filter) || !empty($add) || !empty($update)) {
331
				throw new AmpacheException("Arguments 'filter', 'add', and 'update' are not supported for the type 'album_artist'", 400);
332
			}
333
			$entities = $this->artistBusinessLayer->findAllHavingAlbums($this->session->getUserId(), SortBy::Name, $limit, $offset);
334
			$type = 'artist';
335
		} else {
336
			$businessLayer = $this->getBusinessLayer($type);
337
			$entities = $this->findEntities($businessLayer, $filter, false, $limit, $offset, $add, $update);
338
		}
339
		return $this->renderEntitiesIndex($entities, $type);
340
	}
341
342
	/**
343
	 * @AmpacheAPI
344
	 */
345
	protected function stats(string $type, ?string $filter, int $limit, int $offset=0) : array {
346
		$userId = $this->session->getUserId();
347
348
		// Support for API v3.x: Originally, there was no 'filter' argument and the 'type'
349
		// argument had that role. The action only supported albums in this old format.
350
		// The 'filter' argument was added and role of 'type' changed in API v4.0.
351
		if (empty($filter)) {
352
			$filter = $type;
353
			$type = 'album';
354
		}
355
356
		// Note: In addition to types specified in APIv6, we support also types 'genre' and 'live_stream'
357
		// as that's possible without extra effort. All types don't support all possible filters.
358
		$businessLayer = $this->getBusinessLayer($type);
359
360
		$getEntitiesIfSupported = function(
361
				BusinessLayer $businessLayer, string $method, string $userId,
362
				int $limit, int $offset) use ($type, $filter) {
363
			if (\method_exists($businessLayer, $method)) {
364
				return $businessLayer->$method($userId, $limit, $offset);
365
			} else {
366
				throw new AmpacheException("Filter $filter not supported for type $type", 400);
367
			}
368
		};
369
370
		switch ($filter) {
371
			case 'newest':
372
				$entities = $businessLayer->findAll($userId, SortBy::Newest, $limit, $offset);
373
				break;
374
			case 'flagged':
375
				$entities = $businessLayer->findAllStarred($userId, $limit, $offset);
376
				break;
377
			case 'random':
378
				$entities = $businessLayer->findAll($userId, SortBy::None);
379
				$indices = $this->random->getIndices(\count($entities), $offset, $limit, $userId, 'ampache_stats_'.$type);
380
				$entities = Util::arrayMultiGet($entities, $indices);
381
				break;
382
			case 'frequent':
383
				$entities = $getEntitiesIfSupported($businessLayer, 'findFrequentPlay', $userId, $limit, $offset);
384
				break;
385
			case 'recent':
386
				$entities = $getEntitiesIfSupported($businessLayer, 'findRecentPlay', $userId, $limit, $offset);
387
				break;
388
			case 'forgotten':
389
				$entities = $getEntitiesIfSupported($businessLayer, 'findNotRecentPlay', $userId, $limit, $offset);
390
				break;
391
			case 'highest': //TODO
392
			default:
393
				throw new AmpacheException("Unsupported filter $filter", 400);
394
		}
395
396
		return $this->renderEntities($entities, $type);
397
	}
398
399
	/**
400
	 * @AmpacheAPI
401
	 */
402
	protected function artists(
403
			?string $filter, ?string $add, ?string $update,
404
			int $limit, int $offset=0, bool $exact=false) : array {
405
406
		$artists = $this->findEntities($this->artistBusinessLayer, $filter, $exact, $limit, $offset, $add, $update);
407
		return $this->renderArtists($artists);
408
	}
409
410
	/**
411
	 * @AmpacheAPI
412
	 */
413
	protected function artist(int $filter) : array {
414
		$userId = $this->session->getUserId();
415
		$artist = $this->artistBusinessLayer->find($filter, $userId);
416
		return $this->renderArtists([$artist]);
417
	}
418
419
	/**
420
	 * @AmpacheAPI
421
	 */
422
	protected function artist_albums(int $filter, int $limit, int $offset=0) : array {
423
		$userId = $this->session->getUserId();
424
		$albums = $this->albumBusinessLayer->findAllByArtist($filter, $userId, $limit, $offset);
425
		return $this->renderAlbums($albums);
426
	}
427
428
	/**
429
	 * @AmpacheAPI
430
	 */
431
	protected function artist_songs(int $filter, int $limit, int $offset=0) : array {
432
		$userId = $this->session->getUserId();
433
		$tracks = $this->trackBusinessLayer->findAllByArtist($filter, $userId, $limit, $offset);
434
		return $this->renderSongs($tracks);
435
	}
436
437
	/**
438
	 * @AmpacheAPI
439
	 */
440
	protected function album_songs(int $filter, int $limit, int $offset=0) : array {
441
		$userId = $this->session->getUserId();
442
		$tracks = $this->trackBusinessLayer->findAllByAlbum($filter, $userId, null, $limit, $offset);
443
		return $this->renderSongs($tracks);
444
	}
445
446
	/**
447
	 * @AmpacheAPI
448
	 */
449
	protected function song(int $filter) : array {
450
		$userId = $this->session->getUserId();
451
		$track = $this->trackBusinessLayer->find($filter, $userId);
452
		$trackInArray = [$track];
453
		return $this->renderSongs($trackInArray);
454
	}
455
456
	/**
457
	 * @AmpacheAPI
458
	 */
459
	protected function songs(
460
			?string $filter, ?string $add, ?string $update,
461
			int $limit, int $offset=0, bool $exact=false) : array {
462
463
		$tracks = $this->findEntities($this->trackBusinessLayer, $filter, $exact, $limit, $offset, $add, $update);
464
		return $this->renderSongs($tracks);
465
	}
466
467
	/**
468
	 * @AmpacheAPI
469
	 */
470
	protected function search_songs(string $filter, int $limit, int $offset=0) : array {
471
		$userId = $this->session->getUserId();
472
		$tracks = $this->trackBusinessLayer->findAllByNameRecursive($filter, $userId, $limit, $offset);
473
		return $this->renderSongs($tracks);
474
	}
475
476
	/**
477
	 * @AmpacheAPI
478
	 */
479
	protected function albums(
480
			?string $filter, ?string $add, ?string $update,
481
			int $limit, int $offset=0, bool $exact=false) : array {
482
483
		$albums = $this->findEntities($this->albumBusinessLayer, $filter, $exact, $limit, $offset, $add, $update);
484
		return $this->renderAlbums($albums);
485
	}
486
487
	/**
488
	 * @AmpacheAPI
489
	 */
490
	protected function album(int $filter) : array {
491
		$userId = $this->session->getUserId();
492
		$album = $this->albumBusinessLayer->find($filter, $userId);
493
		return $this->renderAlbums([$album]);
494
	}
495
496
	/**
497
	 * @AmpacheAPI
498
	 */
499
	protected function get_similar(string $type, int $filter, int $limit, int $offset=0) : array {
500
		$userId = $this->session->getUserId();
501
		if ($type == 'artist') {
502
			$entities = $this->lastfmService->getSimilarArtists($filter, $userId);
503
		} elseif ($type == 'song') {
504
			$entities = $this->lastfmService->getSimilarTracks($filter, $userId);
505
		} else {
506
			throw new AmpacheException("Type '$type' is not supported", 400);
507
		}
508
		$entities = \array_slice($entities, $offset, $limit);
509
		return $this->renderEntitiesIndex($entities, $type);
510
	}
511
512
	/**
513
	 * @AmpacheAPI
514
	 */
515
	protected function playlists(
516
			?string $filter, ?string $add, ?string $update,
517
			int $limit, int $offset=0, bool $exact=false, int $hide_search=0) : array {
518
519
		$userId = $this->session->getUserId();
520
		$playlists = $this->findEntities($this->playlistBusinessLayer, $filter, $exact, $limit, $offset, $add, $update);
521
522
		// append "All tracks" if "seaches" are not forbidden, and not filtering by any criteria, and it is not off-limits
523
		$allTracksIndex = $this->playlistBusinessLayer->count($userId);
524
		if (!$hide_search && empty($filter) && empty($add) && empty($update)
525
				&& self::indexIsWithinOffsetAndLimit($allTracksIndex, $offset, $limit)) {
526
			$playlists[] = new AmpacheController_AllTracksPlaylist($userId, $this->trackBusinessLayer, $this->l10n);
527
		}
528
529
		return $this->renderPlaylists($playlists);
530
	}
531
532
	/**
533
	 * @AmpacheAPI
534
	 */
535
	protected function playlist(int $filter) : array {
536
		$userId = $this->session->getUserId();
537
		if ($filter == self::ALL_TRACKS_PLAYLIST_ID) {
538
			$playlist = new AmpacheController_AllTracksPlaylist($userId, $this->trackBusinessLayer, $this->l10n);
539
		} else {
540
			$playlist = $this->playlistBusinessLayer->find($filter, $userId);
541
		}
542
		return $this->renderPlaylists([$playlist]);
543
	}
544
545
	/**
546
	 * @AmpacheAPI
547
	 */
548
	protected function playlist_songs(int $filter, int $limit, int $offset=0) : array {
549
		$userId = $this->session->getUserId();
550
		if ($filter == self::ALL_TRACKS_PLAYLIST_ID) {
551
			$tracks = $this->trackBusinessLayer->findAll($userId, SortBy::Parent, $limit, $offset);
552
			foreach ($tracks as $index => &$track) {
553
				$track->setNumberOnPlaylist($index + 1);
554
			}
555
		} else {
556
			$tracks = $this->playlistBusinessLayer->getPlaylistTracks($filter, $userId, $limit, $offset);
557
		}
558
		return $this->renderSongs($tracks);
559
	}
560
561
	/**
562
	 * @AmpacheAPI
563
	 */
564
	protected function playlist_create(string $name) : array {
565
		$playlist = $this->playlistBusinessLayer->create($name, $this->session->getUserId());
566
		return $this->renderPlaylists([$playlist]);
567
	}
568
569
	/**
570
	 * @AmpacheAPI
571
	 *
572
	 * @param int $filter Playlist ID
573
	 * @param ?string $name New name for the playlist
574
	 * @param ?string $items Track IDs
575
	 * @param ?string $tracks 1-based indices of the tracks
576
	 */
577
	protected function playlist_edit(int $filter, ?string $name, ?string $items, ?string $tracks) : array {
578
		$edited = false;
579
		$userId = $this->session->getUserId();
580
		$playlist = $this->playlistBusinessLayer->find($filter, $userId);
581
582
		if (!empty($name)) {
583
			$playlist->setName($name);
584
			$edited = true;
585
		}
586
587
		$newTrackIds = Util::explode(',', $items);
588
		$newTrackOrdinals = Util::explode(',', $tracks);
589
590
		if (\count($newTrackIds) != \count($newTrackOrdinals)) {
591
			throw new AmpacheException("Arguments 'items' and 'tracks' must contain equal amount of elements", 400);
592
		} elseif (\count($newTrackIds) > 0) {
593
			$trackIds = $playlist->getTrackIdsAsArray();
594
595
			for ($i = 0, $count = \count($newTrackIds); $i < $count; ++$i) {
596
				$trackId = $newTrackIds[$i];
597
				if (!$this->trackBusinessLayer->exists($trackId, $userId)) {
598
					throw new AmpacheException("Invalid song ID $trackId", 404);
599
				}
600
				$trackIds[$newTrackOrdinals[$i]-1] = $trackId;
601
			}
602
603
			$playlist->setTrackIdsFromArray($trackIds);
604
			$edited = true;
605
		}
606
607
		if ($edited) {
608
			$this->playlistBusinessLayer->update($playlist);
609
			return ['success' => 'playlist changes saved'];
610
		} else {
611
			throw new AmpacheException('Nothing was changed', 400);
612
		}
613
	}
614
615
	/**
616
	 * @AmpacheAPI
617
	 */
618
	protected function playlist_delete(int $filter) : array {
619
		$this->playlistBusinessLayer->delete($filter, $this->session->getUserId());
620
		return ['success' => 'playlist deleted'];
621
	}
622
623
	/**
624
	 * @AmpacheAPI
625
	 */
626
	protected function playlist_add_song(int $filter, int $song, bool $check=false) : array {
627
		$userId = $this->session->getUserId();
628
		if (!$this->trackBusinessLayer->exists($song, $userId)) {
629
			throw new AmpacheException("Invalid song ID $song", 404);
630
		}
631
632
		$playlist = $this->playlistBusinessLayer->find($filter, $userId);
633
		$trackIds = $playlist->getTrackIdsAsArray();
634
635
		if ($check && \in_array($song, $trackIds)) {
636
			throw new AmpacheException("Can't add a duplicate item when check is enabled", 400);
637
		}
638
639
		$trackIds[] = $song;
640
		$playlist->setTrackIdsFromArray($trackIds);
641
		$this->playlistBusinessLayer->update($playlist);
642
		return ['success' => 'song added to playlist'];
643
	}
644
645
	/**
646
	 * @AmpacheAPI
647
	 *
648
	 * @param int $filter Playlist ID
649
	 * @param ?int $song Track ID
650
	 * @param ?int $track 1-based index of the track
651
	 * @param ?int $clear Value 1 erases all the songs from the playlist
652
	 */
653
	protected function playlist_remove_song(int $filter, ?int $song, ?int $track, ?int $clear) : array {
654
		$playlist = $this->playlistBusinessLayer->find($filter, $this->session->getUserId());
655
656
		if ($clear === 1) {
657
			$trackIds = [];
658
			$message = 'all songs removed from playlist';
659
		} elseif ($song !== null) {
660
			$trackIds = $playlist->getTrackIdsAsArray();
661
			if (!\in_array($song, $trackIds)) {
662
				throw new AmpacheException("Song $song not found in playlist", 404);
663
			}
664
			$trackIds = Util::arrayDiff($trackIds, [$song]);
665
			$message = 'song removed from playlist';
666
		} elseif ($track !== null) {
667
			$trackIds = $playlist->getTrackIdsAsArray();
668
			if ($track < 1 || $track > \count($trackIds)) {
669
				throw new AmpacheException("Track ordinal $track is out of bounds", 404);
670
			}
671
			unset($trackIds[$track-1]);
672
			$message = 'song removed from playlist';
673
		} else {
674
			throw new AmpacheException("One of the arguments 'clear', 'song', 'track' is required", 400);
675
		}
676
677
		$playlist->setTrackIdsFromArray($trackIds);
678
		$this->playlistBusinessLayer->update($playlist);
679
		return ['success' => $message];
680
	}
681
682
	/**
683
	 * @AmpacheAPI
684
	 */
685
	protected function playlist_generate(
686
			?string $filter, ?int $album, ?int $artist, ?int $flag,
687
			int $limit, int $offset=0, string $mode='random', string $format='song') : array {
688
689
		$tracks = $this->findEntities($this->trackBusinessLayer, $filter, false); // $limit and $offset are applied later
690
691
		// filter the found tracks according to the additional requirements
692
		if ($album !== null) {
693
			$tracks = \array_filter($tracks, function ($track) use ($album) {
694
				return ($track->getAlbumId() == $album);
695
			});
696
		}
697
		if ($artist !== null) {
698
			$tracks = \array_filter($tracks, function ($track) use ($artist) {
699
				return ($track->getArtistId() == $artist);
700
			});
701
		}
702
		if ($flag == 1) {
703
			$tracks = \array_filter($tracks, function ($track) {
704
				return ($track->getStarred() !== null);
705
			});
706
		}
707
		// After filtering, there may be "holes" between the array indices. Reindex the array.
708
		$tracks = \array_values($tracks);
709
710
		if ($mode == 'random') {
711
			$userId = $this->session->getUserId();
712
			$indices = $this->random->getIndices(\count($tracks), $offset, $limit, $userId, 'ampache_playlist_generate');
713
			$tracks = Util::arrayMultiGet($tracks, $indices);
714
		} else { // 'recent', 'forgotten', 'unplayed'
715
			throw new AmpacheException("Mode '$mode' is not supported", 400);
716
		}
717
718
		switch ($format) {
719
			case 'song':
720
				return $this->renderSongs($tracks);
721
			case 'index':
722
				return $this->renderSongsIndex($tracks);
723
			case 'id':
724
				return $this->renderEntityIds($tracks);
725
			default:
726
				throw new AmpacheException("Format '$format' is not supported", 400);
727
		}
728
	}
729
730
	/**
731
	 * @AmpacheAPI
732
	 */
733
	protected function podcasts(?string $filter, ?string $include, int $limit, int $offset=0, bool $exact=false) : array {
734
		$channels = $this->findEntities($this->podcastChannelBusinessLayer, $filter, $exact, $limit, $offset);
735
736
		if ($include === 'episodes') {
737
			$userId = $this->session->getUserId();
738
			$actuallyLimited = ($limit < $this->podcastChannelBusinessLayer->count($userId));
739
			$allChannelsIncluded = (!$filter && !$actuallyLimited && !$offset);
740
			$this->podcastService->injectEpisodes($channels, $userId, $allChannelsIncluded);
741
		}
742
743
		return $this->renderPodcastChannels($channels);
744
	}
745
746
	/**
747
	 * @AmpacheAPI
748
	 */
749
	protected function podcast(int $filter, ?string $include) : array {
750
		$userId = $this->session->getUserId();
751
		$channel = $this->podcastChannelBusinessLayer->find($filter, $userId);
752
753
		if ($include === 'episodes') {
754
			$channel->setEpisodes($this->podcastEpisodeBusinessLayer->findAllByChannel($filter, $userId));
755
		}
756
757
		return $this->renderPodcastChannels([$channel]);
758
	}
759
760
	/**
761
	 * @AmpacheAPI
762
	 */
763
	protected function podcast_create(string $url) : array {
764
		$userId = $this->session->getUserId();
765
		$result = $this->podcastService->subscribe($url, $userId);
766
767
		switch ($result['status']) {
768
			case PodcastService::STATUS_OK:
769
				return $this->renderPodcastChannels([$result['channel']]);
770
			case PodcastService::STATUS_INVALID_URL:
771
				throw new AmpacheException("Invalid URL $url", 400);
772
			case PodcastService::STATUS_INVALID_RSS:
773
				throw new AmpacheException("The document at URL $url is not a valid podcast RSS feed", 400);
774
			case PodcastService::STATUS_ALREADY_EXISTS:
775
				throw new AmpacheException('User already has this podcast channel subscribed', 400);
776
			default:
777
				throw new AmpacheException("Unexpected status code {$result['status']}", 400);
778
		}
779
	}
780
781
	/**
782
	 * @AmpacheAPI
783
	 */
784
	protected function podcast_delete(int $filter) : array {
785
		$userId = $this->session->getUserId();
786
		$status = $this->podcastService->unsubscribe($filter, $userId);
787
788
		switch ($status) {
789
			case PodcastService::STATUS_OK:
790
				return ['success' => 'podcast deleted'];
791
			case PodcastService::STATUS_NOT_FOUND:
792
				throw new AmpacheException('Channel to be deleted not found', 404);
793
			default:
794
				throw new AmpacheException("Unexpected status code $status", 400);
795
		}
796
	}
797
798
	/**
799
	 * @AmpacheAPI
800
	 */
801
	protected function podcast_episodes(int $filter, int $limit, int $offset=0) : array {
802
		$userId = $this->session->getUserId();
803
		$episodes = $this->podcastEpisodeBusinessLayer->findAllByChannel($filter, $userId, $limit, $offset);
804
		return $this->renderPodcastEpisodes($episodes);
805
	}
806
807
	/**
808
	 * @AmpacheAPI
809
	 */
810
	protected function podcast_episode(int $filter) : array {
811
		$userId = $this->session->getUserId();
812
		$episode = $this->podcastEpisodeBusinessLayer->find($filter, $userId);
813
		return $this->renderPodcastEpisodes([$episode]);
814
	}
815
816
	/**
817
	 * @AmpacheAPI
818
	 */
819
	protected function update_podcast(int $id) : array {
820
		$userId = $this->session->getUserId();
821
		$result = $this->podcastService->updateChannel($id, $userId);
822
823
		switch ($result['status']) {
824
			case PodcastService::STATUS_OK:
825
				$message = $result['updated'] ? 'channel was updated from the source' : 'no changes found';
826
				return ['success' => $message];
827
			case PodcastService::STATUS_NOT_FOUND:
828
				throw new AmpacheException('Channel to be updated not found', 404);
829
			case PodcastService::STATUS_INVALID_URL:
830
				throw new AmpacheException('failed to read from the channel URL', 400);
831
			case PodcastService::STATUS_INVALID_RSS:
832
				throw new AmpacheException('the document at the channel URL is not a valid podcast RSS feed', 400);
833
			default:
834
				throw new AmpacheException("Unexpected status code {$result['status']}", 400);
835
		}
836
	}
837
838
	/**
839
	 * @AmpacheAPI
840
	 */
841
	protected function live_streams(?string $filter, int $limit, int $offset=0, bool $exact=false) : array {
842
		$stations = $this->findEntities($this->radioStationBusinessLayer, $filter, $exact, $limit, $offset);
843
		return $this->renderLiveStreams($stations);
844
	}
845
846
	/**
847
	 * @AmpacheAPI
848
	 */
849
	protected function live_stream(int $filter) : array {
850
		$station = $this->radioStationBusinessLayer->find($filter, $this->session->getUserId());
851
		return $this->renderLiveStreams([$station]);
852
	}
853
854
	/**
855
	 * @AmpacheAPI
856
	 */
857
	protected function live_stream_create(string $name, string $url, ?string $site_url) : array {
858
		$station = $this->radioStationBusinessLayer->create($this->session->getUserId(), $name, $url, $site_url);
859
		return $this->renderLiveStreams([$station]);
860
	}
861
862
	/**
863
	 * @AmpacheAPI
864
	 */
865
	protected function live_stream_delete(int $filter) : array {
866
		$this->radioStationBusinessLayer->delete($filter, $this->session->getUserId());
867
		return ['success' => "Deleted live stream: $filter"];
868
	}
869
870
	/**
871
	 * @AmpacheAPI
872
	 */
873
	protected function live_stream_edit(int $filter, ?string $name, ?string $url, ?string $site_url) : array {
874
		$station = $this->radioStationBusinessLayer->find($filter, $this->session->getUserId());
875
876
		if ($name !== null) {
877
			$station->setName($name);
878
		}
879
		if ($url !== null) {
880
			$station->setStreamUrl($url);
881
		}
882
		if ($site_url !== null) {
883
			$station->setHomeUrl($site_url);
884
		}
885
		$station = $this->radioStationBusinessLayer->update($station);
886
887
		return $this->renderLiveStreams([$station]);
888
	}
889
890
	/**
891
	 * @AmpacheAPI
892
	 */
893
	protected function tags(?string $filter, int $limit, int $offset=0, bool $exact=false) : array {
894
		$genres = $this->findEntities($this->genreBusinessLayer, $filter, $exact, $limit, $offset);
895
		return $this->renderTags($genres);
896
	}
897
898
	/**
899
	 * @AmpacheAPI
900
	 */
901
	protected function tag(int $filter) : array {
902
		$userId = $this->session->getUserId();
903
		$genre = $this->genreBusinessLayer->find($filter, $userId);
904
		return $this->renderTags([$genre]);
905
	}
906
907
	/**
908
	 * @AmpacheAPI
909
	 */
910
	protected function tag_artists(int $filter, int $limit, int $offset=0) : array {
911
		$userId = $this->session->getUserId();
912
		$artists = $this->artistBusinessLayer->findAllByGenre($filter, $userId, $limit, $offset);
913
		return $this->renderArtists($artists);
914
	}
915
916
	/**
917
	 * @AmpacheAPI
918
	 */
919
	protected function tag_albums(int $filter, int $limit, int $offset=0) : array {
920
		$userId = $this->session->getUserId();
921
		$albums = $this->albumBusinessLayer->findAllByGenre($filter, $userId, $limit, $offset);
922
		return $this->renderAlbums($albums);
923
	}
924
925
	/**
926
	 * @AmpacheAPI
927
	 */
928
	protected function tag_songs(int $filter, int $limit, int $offset=0) : array {
929
		$userId = $this->session->getUserId();
930
		$tracks = $this->trackBusinessLayer->findAllByGenre($filter, $userId, $limit, $offset);
931
		return $this->renderSongs($tracks);
932
	}
933
934
	/**
935
	 * @AmpacheAPI
936
	 */
937
	protected function genres(?string $filter, int $limit, int $offset=0, bool $exact=false) : array {
938
		$genres = $this->findEntities($this->genreBusinessLayer, $filter, $exact, $limit, $offset);
939
		return $this->renderGenres($genres);
940
	}
941
942
	/**
943
	 * @AmpacheAPI
944
	 */
945
	protected function genre(int $filter) : array {
946
		$userId = $this->session->getUserId();
947
		$genre = $this->genreBusinessLayer->find($filter, $userId);
948
		return $this->renderGenres([$genre]);
949
	}
950
951
	/**
952
	 * @AmpacheAPI
953
	 */
954
	protected function genre_artists(?int $filter, int $limit, int $offset=0) : array {
955
		if ($filter === null) {
956
			return $this->artists(null, null, null, $limit, $offset);
957
		} else {
958
			return $this->tag_artists($filter, $limit, $offset);
959
		}
960
	}
961
962
	/**
963
	 * @AmpacheAPI
964
	 */
965
	protected function genre_albums(?int $filter, int $limit, int $offset=0) : array {
966
		if ($filter === null) {
967
			return $this->albums(null, null, null, $limit, $offset);
968
		} else {
969
			return $this->tag_albums($filter, $limit, $offset);
970
		}
971
	}
972
973
	/**
974
	 * @AmpacheAPI
975
	 */
976
	protected function genre_songs(?int $filter, int $limit, int $offset=0) : array {
977
		if ($filter === null) {
978
			return $this->songs(null, null, null, $limit, $offset);
979
		} else {
980
			return $this->tag_songs($filter, $limit, $offset);
981
		}
982
	}
983
984
	/**
985
	 * @AmpacheAPI
986
	 */
987
	protected function bookmarks() : array {
988
		$bookmarks = $this->bookmarkBusinessLayer->findAll($this->session->getUserId());
989
		return $this->renderBookmarks($bookmarks);
990
	}
991
992
	/**
993
	 * @AmpacheAPI
994
	 */
995
	protected function get_bookmark(int $filter, string $type) : array {
996
		$entryType = self::mapBookmarkType($type);
997
		$bookmark = $this->bookmarkBusinessLayer->findByEntry($entryType, $filter, $this->session->getUserId());
998
		return $this->renderBookmarks([$bookmark]);
999
	}
1000
1001
	/**
1002
	 * @AmpacheAPI
1003
	 */
1004
	protected function bookmark_create(int $filter, string $type, int $position, string $client='AmpacheAPI') : array {
1005
		// Note: the optional argument 'date' is not supported and is disregarded
1006
		$entryType = self::mapBookmarkType($type);
1007
		$position *= 1000; // seconds to milliseconds
1008
		$bookmark = $this->bookmarkBusinessLayer->addOrUpdate($this->session->getUserId(), $entryType, $filter, $position, $client);
1009
		return $this->renderBookmarks([$bookmark]);
1010
	}
1011
1012
	/**
1013
	 * @AmpacheAPI
1014
	 */
1015
	protected function bookmark_edit(int $filter, string $type, int $position, ?string $client) : array {
1016
		// Note: the optional argument 'date' is not supported and is disregarded
1017
		$entryType = self::mapBookmarkType($type);
1018
		$bookmark = $this->bookmarkBusinessLayer->findByEntry($entryType, $filter, $this->session->getUserId());
1019
		$bookmark->setPosition($position * 1000); // seconds to milliseconds
1020
		if ($client !== null) {
1021
			$bookmark->setComment($client);
1022
		}
1023
		$bookmark = $this->bookmarkBusinessLayer->update($bookmark);
1024
		return $this->renderBookmarks([$bookmark]);
1025
	}
1026
1027
	/**
1028
	 * @AmpacheAPI
1029
	 */
1030
	protected function bookmark_delete(int $filter, string $type) : array {
1031
		$entryType = self::mapBookmarkType($type);
1032
		$bookmark = $this->bookmarkBusinessLayer->findByEntry($entryType, $filter, $this->session->getUserId());
1033
		$this->bookmarkBusinessLayer->delete($bookmark->getId(), $bookmark->getUserId());
1034
		return ['success' => "Deleted Bookmark: $type $filter"];
1035
	}
1036
1037
	/**
1038
	 * @AmpacheAPI
1039
	 */
1040
	protected function advanced_search(
1041
			string $type, string $operator, 
1042
			string $rule_1, int $rule_1_operator, string $rule_1_input,
1043
			int $limit, int $offset=0, bool $random=false) : array {
1044
		$businessLayer = $this->getBusinessLayer($type);
1045
		$rule_1 = self::advSearchResolveRuleAlias($rule_1);
1046
		$rules = [[
1047
			'rule' => $rule_1,
1048
			'operator' => self::advSearchInterpretOperator($rule_1_operator, $rule_1),
1049
			'input' => self::advSearchConvertInput($rule_1_input, $rule_1)
1050
		]]; // TODO: support multiple rules
1051
1052
		try {
1053
			$entities = $businessLayer->findAllAdvanced($operator, $rules, $random, $this->session->getUserId(), $limit, $offset);
1054
		} catch (BusinessLayerException $e) {
1055
			throw new AmpacheException($e->getMessage(), 400);
1056
		}
1057
		
1058
		return $this->renderEntities($entities, $type);
1059
	}
1060
1061
	/**
1062
	 * @AmpacheAPI
1063
	 */
1064
	protected function flag(string $type, int $id, bool $flag) : array {
1065
		if (!\in_array($type, ['song', 'album', 'artist', 'podcast', 'podcast_episode'])) {
1066
			throw new AmpacheException("Unsupported type $type", 400);
1067
		}
1068
1069
		$userId = $this->session->getUserId();
1070
		$businessLayer = $this->getBusinessLayer($type);
1071
		if ($flag) {
1072
			$modifiedCount = $businessLayer->setStarred([$id], $userId);
1073
			$message = "flag ADDED to $type $id";
1074
		} else {
1075
			$modifiedCount = $businessLayer->unsetStarred([$id], $userId);
1076
			$message = "flag REMOVED from $type $id";
1077
		}
1078
1079
		if ($modifiedCount > 0) {
1080
			return ['success' => $message];
1081
		} else {
1082
			throw new AmpacheException("The $type $id was not found", 404);
1083
		}
1084
	}
1085
1086
	/**
1087
	 * @AmpacheAPI
1088
	 */
1089
	protected function record_play(int $id, ?int $date) : array {
1090
		$timeOfPlay = ($date === null) ? null : new \DateTime('@' . $date);
1091
		$this->trackBusinessLayer->recordTrackPlayed($id, $this->session->getUserId(), $timeOfPlay);
1092
		return ['success' => 'play recorded'];
1093
	}
1094
1095
	/**
1096
	 * @AmpacheAPI
1097
	 */
1098
	protected function download(int $id, string $type='song') : Response {
1099
		// request param `format` is ignored
1100
		$userId = $this->session->getUserId();
1101
1102
		if ($type === 'song') {
1103
			try {
1104
				$track = $this->trackBusinessLayer->find($id, $userId);
1105
			} catch (BusinessLayerException $e) {
1106
				return new ErrorResponse(Http::STATUS_NOT_FOUND, $e->getMessage());
1107
			}
1108
1109
			$file = $this->librarySettings->getFolder($userId)->getById($track->getFileId())[0] ?? null;
1110
1111
			if ($file instanceof \OCP\Files\File) {
1112
				return new FileStreamResponse($file);
1113
			} else {
1114
				return new ErrorResponse(Http::STATUS_NOT_FOUND);
1115
			}
1116
		} elseif ($type === 'podcast' || $type === 'podcast_episode') { // there's a difference between APIv4 and APIv5
1117
			$episode = $this->podcastEpisodeBusinessLayer->find($id, $userId);
1118
			return new RedirectResponse($episode->getStreamUrl());
1119
		} elseif ($type === 'playlist') {
1120
			$songIds = ($id === self::ALL_TRACKS_PLAYLIST_ID)
1121
				? $this->trackBusinessLayer->findAllIds($userId)
1122
				: $this->playlistBusinessLayer->find($id, $userId)->getTrackIdsAsArray();
1123
			if (empty($songIds)) {
1124
				throw new AmpacheException("The playlist $id is empty", 404);
1125
			} else {
1126
				return $this->download(Random::pickItem($songIds));
0 ignored issues
show
Bug introduced by
It seems like OCA\Music\Utility\Random::pickItem($songIds) can also be of type null; however, parameter $id of OCA\Music\Controller\AmpacheController::download() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1126
				return $this->download(/** @scrutinizer ignore-type */ Random::pickItem($songIds));
Loading history...
1127
			}
1128
		} else {
1129
			throw new AmpacheException("Unsupported type '$type'", 400);
1130
		}
1131
	}
1132
1133
	/**
1134
	 * @AmpacheAPI
1135
	 */
1136
	protected function stream(int $id, ?int $offset, string $type='song') : Response {
1137
		// request params `bitrate`, `format`, and `length` are ignored
1138
1139
		// This is just a dummy implementation. We don't support transcoding or streaming
1140
		// from a time offset.
1141
		// All the other unsupported arguments are just ignored, but a request with an offset
1142
		// is responded with an error. This is becuase the client would probably work in an
1143
		// unexpected way if it thinks it's streaming from offset but actually it is streaming
1144
		// from the beginning of the file. Returning an error gives the client a chance to fallback
1145
		// to other methods of seeking.
1146
		if ($offset !== null) {
1147
			throw new AmpacheException('Streaming with time offset is not supported', 400);
1148
		}
1149
1150
		return $this->download($id, $type);
1151
	}
1152
1153
	/**
1154
	 * @AmpacheAPI
1155
	 */
1156
	protected function get_art(string $type, int $id) : Response {
1157
		if (!\in_array($type, ['song', 'album', 'artist', 'podcast', 'playlist'])) {
1158
			throw new AmpacheException("Unsupported type $type", 400);
1159
		}
1160
1161
		if ($type === 'song') {
1162
			// map song to its parent album
1163
			$id = $this->trackBusinessLayer->find($id, $this->session->getUserId())->getAlbumId();
1164
			$type = 'album';
1165
		}
1166
1167
		return $this->getCover($id, $this->getBusinessLayer($type));
1168
	}
1169
1170
	/********************
1171
	 * Helper functions *
1172
	 ********************/
1173
1174
	private function getBusinessLayer(string $type) : BusinessLayer {
1175
		switch ($type) {
1176
			case 'song':			return $this->trackBusinessLayer;
1177
			case 'album':			return $this->albumBusinessLayer;
1178
			case 'artist':			return $this->artistBusinessLayer;
1179
			case 'playlist':		return $this->playlistBusinessLayer;
1180
			case 'podcast':			return $this->podcastChannelBusinessLayer;
1181
			case 'podcast_episode':	return $this->podcastEpisodeBusinessLayer;
1182
			case 'live_stream':		return $this->radioStationBusinessLayer;
1183
			case 'tag':				return $this->genreBusinessLayer;
1184
			case 'genre':			return $this->genreBusinessLayer;
1185
			case 'bookmark':		return $this->bookmarkBusinessLayer;
1186
			default:				throw new AmpacheException("Unsupported type $type", 400);
1187
		}
1188
	}
1189
1190
	private function renderEntities(array $entities, string $type) : array {
1191
		switch ($type) {
1192
			case 'song':			return $this->renderSongs($entities);
1193
			case 'album':			return $this->renderAlbums($entities);
1194
			case 'artist':			return $this->renderArtists($entities);
1195
			case 'playlist':		return $this->renderPlaylists($entities);
1196
			case 'podcast':			return $this->renderPodcastChannels($entities);
1197
			case 'podcast_episode':	return $this->renderPodcastEpisodes($entities);
1198
			case 'live_stream':		return $this->renderLiveStreams($entities);
1199
			case 'tag':				return $this->renderTags($entities);
1200
			case 'genre':			return $this->renderGenres($entities);
1201
			case 'bookmark':		return $this->renderBookmarks($entities);
1202
			default:				throw new AmpacheException("Unsupported type $type", 400);
1203
		}
1204
	}
1205
1206
	private function renderEntitiesIndex($entities, $type) : array {
1207
		switch ($type) {
1208
			case 'song':			return $this->renderSongsIndex($entities);
1209
			case 'album':			return $this->renderAlbumsIndex($entities);
1210
			case 'artist':			return $this->renderArtistsIndex($entities);
1211
			case 'playlist':		return $this->renderPlaylistsIndex($entities);
1212
			case 'podcast':			return $this->renderPodcastChannelsIndex($entities);
1213
			case 'podcast_episode':	return $this->renderPodcastEpisodesIndex($entities);
1214
			case 'live_stream':		return $this->renderLiveStreamsIndex($entities);
1215
			case 'tag':				return $this->renderTags($entities); // not part of the API spec
1216
			case 'genre':			return $this->renderGenres($entities); // not part of the API spec
1217
			case 'bookmark':		return $this->renderBookmarks($entities); // not part of the API spec
1218
			default:				throw new AmpacheException("Unsupported type $type", 400);
1219
		}
1220
	}
1221
1222
	private static function mapBookmarkType(string $ampacheType) : int {
1223
		switch ($ampacheType) {
1224
			case 'song':			return Bookmark::TYPE_TRACK;
1225
			case 'podcast_episode':	return Bookmark::TYPE_PODCAST_EPISODE;
1226
			default:				throw new AmpacheException("Unsupported type $ampacheType", 400);
1227
		}
1228
	}
1229
1230
	private static function advSearchResolveRuleAlias(string $rule) : string {
1231
		switch ($rule) {
1232
			case 'name':					return 'title';
1233
			case 'song_title':				return 'song';
1234
			case 'album_title':				return 'album';
1235
			case 'artist_title':			return 'artist';
1236
			case 'podcast_title':			return 'podcast';
1237
			case 'podcast_episode_title':	return 'podcast_episode';
1238
			case 'album_artist_title':		return 'album_artist';
1239
			case 'song_artist_title':		return 'song_artist';
1240
			case 'tag':						return 'genre';
1241
			case 'song_tag':				return 'song_genre';
1242
			case 'album_tag':				return 'album_genre';
1243
			case 'artist_tag':				return 'artist_genre';
1244
			case 'no_tag':					return 'no_genre';
1245
			default:						return $rule;
1246
		}
1247
	}
1248
1249
	// NOTE: alias rule names should be resolved to their base form before calling this
1250
	private static function advSearchInterpretOperator(int $rule_operator, string $rule) : string {
1251
		// Operator mapping is different for text, numeric, date, boolean, and day rules
1252
1253
		$textRules = [
1254
			'anywhere', 'title', 'song', 'album', 'artist', 'podcast', 'podcast_episode', 'album_artist', 'song_artist',
1255
			'favorite', 'favorite_album', 'favorite_artist', 'genre', 'song_genre', 'album_genre', 'artist_genre',
1256
			'playlist_name', 'type', 'file', 'mbid', 'mbid_album', 'mbid_artist', 'mbid_song'
1257
		];
1258
		// text but no support planned: 'composer', 'summary', 'placeformed', 'release_type', 'release_status', 'barcode',
1259
		// 'catalog_number', 'label', 'comment', 'lyrics', 'username', 'category'
1260
1261
		$numericRules = [
1262
			'track', 'year', 'original_year', 'myrating', 'rating', 'songrating', 'albumrating', 'artistrating',
1263
			'played_times', 'album_count', 'song_count', 'time', 'recent_played', 'recent_added', 'recent_updated'];
1264
		// numeric but no support planned: 'yearformed', 'skipped_times', 'play_skip_ratio', 'image_height', 'image_width'
1265
1266
		$dateOrDayRules = ['added', 'updated', 'pubdate', 'last_play'];
1267
1268
		$booleanRules = [
1269
			'played', 'myplayed', 'myplayedalbum', 'myplayedartist', 'playlist', 'has_image', 'no_genre',
1270
			'my_flagged', 'my_flagged_album', 'my_flagged_artist'
1271
		];
1272
		// boolean but no support planned: 'license', 'smartplaylist', 'state', 'catalog', 'possible_duplicate', 'possible_duplicate_album'
1273
1274
		if (\in_array($rule, $textRules)) {
1275
			switch ($rule_operator) {
1276
				case 0: return 'contain';		// contains
1277
				case 1: return 'notcontain';	// does not contain;
1278
				case 2: return 'start';			// starts with
1279
				case 3: return 'end';			// ends with;
1280
				case 4: return 'is';			// is
1281
				case 5: return 'isnot';			// is not
1282
				case 6: return 'sounds';		// sounds like
1283
				case 7: return 'notsounds';		// does not sound like
1284
				case 8: return 'regexp';		// matches regex
1285
				case 9: return 'notregexp';		// does not match regex
1286
				default: throw new AmpacheException("Search operator '$rule_operator' not supported for 'text' type rules", 400);
1287
			}
1288
		} elseif (\in_array($rule, $numericRules)) {
1289
			switch ($rule_operator) {
1290
				case 0: return '>=';
1291
				case 1: return '<=';
1292
				case 2: return '=';
1293
				case 3: return '!=';
1294
				case 4: return '>';
1295
				case 5: return '<';
1296
				default: throw new AmpacheException("Search operator '$rule_operator' not supported for 'numeric' type rules", 400);
1297
			}
1298
		} elseif (\in_array($rule, $dateOrDayRules)) {
1299
			switch ($rule_operator) {
1300
				case 0: return '<';
1301
				case 1: return '>';
1302
				default: throw new AmpacheException("Search operator '$rule_operator' not supported for 'date' or 'day' type rules", 400);
1303
			}
1304
		} elseif (\in_array($rule, $booleanRules)) {
1305
			switch ($rule_operator) {
1306
				case 0: return 'true';
1307
				case 1: return 'false';
1308
				default: throw new AmpacheException("Search operator '$rule_operator' not supported for 'boolean' type rules", 400);
1309
			}
1310
		} else {
1311
			throw new AmpacheException("Search rule '$rule' not supported", 400);
1312
		}
1313
	}
1314
1315
	private static function advSearchConvertInput(string $input, string $rule) : string {
1316
		switch ($rule) {
1317
			case 'last_play':
1318
				// days diff to ISO date
1319
				$date = new \DateTime("$input days ago");
1320
				return $date->format(BaseMapper::SQL_DATE_FORMAT);
1321
			case 'time':
1322
				// minutes to seconds
1323
				return (string)(int)((float)$input * 60);
1324
			default:
1325
				return $input;
1326
		}
1327
	}
1328
1329
	private function getAppNameAndVersion() : string {
1330
		$vendor = 'owncloud/nextcloud'; // this should get overridden by the next 'include'
1331
		include \OC::$SERVERROOT . '/version.php';
1332
1333
		$appVersion = AppInfo::getVersion();
1334
1335
		return "$vendor {$this->appName} $appVersion";
1336
	}
1337
1338
	private function getCover(int $entityId, BusinessLayer $businessLayer) : Response {
1339
		$userId = $this->session->getUserId();
1340
		$userFolder = $this->librarySettings->getFolder($userId);
1341
1342
		try {
1343
			$entity = $businessLayer->find($entityId, $userId);
1344
			$coverData = $this->coverHelper->getCover($entity, $userId, $userFolder);
1345
			if ($coverData !== null) {
1346
				return new FileResponse($coverData);
1347
			}
1348
		} catch (BusinessLayerException $e) {
1349
			return new ErrorResponse(Http::STATUS_NOT_FOUND, 'entity not found');
1350
		}
1351
1352
		return new ErrorResponse(Http::STATUS_NOT_FOUND, 'entity has no cover');
1353
	}
1354
1355
	private function findEntities(
1356
			BusinessLayer $businessLayer, ?string $filter, bool $exact, ?int $limit=null, ?int $offset=null, ?string $add=null, ?string $update=null) : array {
1357
1358
		$userId = $this->session->getUserId();
1359
1360
		// It's not documented, but Ampache supports also specifying date range on `add` and `update` parameters
1361
		// by using '/' as separator. If there is no such separator, then the value is used as a lower limit.
1362
		$add = Util::explode('/', $add);
1363
		$update = Util::explode('/', $update);
1364
		$addMin = $add[0] ?? null;
1365
		$addMax = $add[1] ?? null;
1366
		$updateMin = $update[0] ?? null;
1367
		$updateMax = $update[1] ?? null;
1368
1369
		if ($filter) {
1370
			$matchMode = $exact ? MatchMode::Exact : MatchMode::Substring;
1371
			return $businessLayer->findAllByName($filter, $userId, $matchMode, $limit, $offset, $addMin, $addMax, $updateMin, $updateMax);
1372
		} else {
1373
			return $businessLayer->findAll($userId, SortBy::Name, $limit, $offset, $addMin, $addMax, $updateMin, $updateMax);
1374
		}
1375
	}
1376
1377
	private function createAmpacheActionUrl(string $action, int $id, ?string $type=null) : string {
1378
		$api = $this->jsonMode ? 'music.ampache.jsonApi' : 'music.ampache.xmlApi';
1379
		$auth = $this->session->getToken();
1380
		return $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute($api))
1381
				. "?action=$action&id=$id&auth=$auth"
1382
				. (!empty($type) ? "&type=$type" : '');
1383
	}
1384
1385
	private function createCoverUrl(Entity $entity) : string {
1386
		if ($entity instanceof Album) {
1387
			$type = 'album';
1388
		} elseif ($entity instanceof Artist) {
1389
			$type = 'artist';
1390
		} elseif ($entity instanceof Playlist) {
1391
			$type = 'playlist';
1392
		} else {
1393
			throw new AmpacheException('unexpeted entity type for cover image', 500);
1394
		}
1395
1396
		if ($type === 'playlist' || $entity->getCoverFileId()) {
0 ignored issues
show
Bug introduced by
The method getCoverFileId() does not exist on OCA\Music\Db\Entity. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

1396
		if ($type === 'playlist' || $entity->/** @scrutinizer ignore-call */ getCoverFileId()) {
Loading history...
1397
			$id = $entity->getId();
1398
			$token = $this->imageService->getToken($type, $id, $this->session->getAmpacheUserId());
1399
			return $this->urlGenerator->getAbsoluteURL(
1400
				$this->urlGenerator->linkToRoute('music.ampacheImage.image') . "?object_type=$type&object_id=$id&token=$token"
1401
			);
1402
		} else {
1403
			return '';
1404
		}
1405
	}
1406
1407
	private static function indexIsWithinOffsetAndLimit(int $index, ?int $offset, ?int $limit) : bool {
1408
		$offset = \intval($offset); // missing offset is interpreted as 0-offset
1409
		return ($limit === null) || ($index >= $offset && $index < $offset + $limit);
1410
	}
1411
1412
	private function prefixAndBaseName(?string $name) : array {
1413
		$parts = ['prefix' => null, 'basename' => $name];
1414
1415
		if ($name !== null) {
1416
			foreach ($this->namePrefixes as $prefix) {
1417
				if (Util::startsWith($name, $prefix . ' ', /*ignoreCase=*/true)) {
1418
					$parts['prefix'] = $prefix;
1419
					$parts['basename'] = \substr($name, \strlen($prefix) + 1);
1420
					break;
1421
				}
1422
			}
1423
		}
1424
1425
		return $parts;
1426
	}
1427
1428
	private function renderAlbumOrArtistRef(int $id, string $name) : array {
1429
		if ($this->apiMajorVersion() > 5) {
1430
			return [
1431
				'id' => (string)$id,
1432
				'name' => $name,
1433
			] + $this->prefixAndBaseName($name);
1434
		} else {
1435
			return [
1436
				'id' => (string)$id,
1437
				'value' => $name
1438
			];
1439
		}
1440
	}
1441
1442
	/**
1443
	 * @param Artist[] $artists
1444
	 */
1445
	private function renderArtists(array $artists) : array {
1446
		$userId = $this->session->getUserId();
1447
		$genreMap = Util::createIdLookupTable($this->genreBusinessLayer->findAll($userId));
1448
		$genreKey = $this->genreKey();
1449
1450
		return [
1451
			'artist' => \array_map(function (Artist $artist) use ($userId, $genreMap, $genreKey) {
1452
				$albumCount = $this->albumBusinessLayer->countByArtist($artist->getId());
1453
				$songCount = $this->trackBusinessLayer->countByArtist($artist->getId());
1454
				$name = $artist->getNameString($this->l10n);
1455
				$nameParts = $this->prefixAndBaseName($name);
1456
				return [
1457
					'id' => (string)$artist->getId(),
1458
					'name' => $name,
1459
					'prefix' => $nameParts['prefix'],
1460
					'basename' => $nameParts['basename'],
1461
					'albums' => $albumCount, // TODO: this should contain objects if requested; in API5+, this never contains the count
1462
					'albumcount' => $albumCount,
1463
					'songs' => $songCount, // TODO: this should contain objects if requested; in API5+, this never contains the count
1464
					'songcount' => $songCount,
1465
					'time' => $this->trackBusinessLayer->totalDurationByArtist($artist->getId()),
1466
					'art' => $this->createCoverUrl($artist),
1467
					'rating' => 0,
1468
					'preciserating' => 0,
1469
					'flag' => !empty($artist->getStarred()),
1470
					$genreKey => \array_map(function ($genreId) use ($genreMap) {
1471
						return [
1472
							'id' => (string)$genreId,
1473
							'value' => $genreMap[$genreId]->getNameString($this->l10n),
1474
							'count' => 1
1475
						];
1476
					}, $this->trackBusinessLayer->getGenresByArtistId($artist->getId(), $userId))
1477
				];
1478
			}, $artists)
1479
		];
1480
	}
1481
1482
	/**
1483
	 * @param Album[] $albums
1484
	 */
1485
	private function renderAlbums(array $albums) : array {
1486
		$genreKey = $this->genreKey();
1487
		// In APIv6 JSON format, there is a new property `artists` with an array value
1488
		$includeArtists = ($this->jsonMode && $this->apiMajorVersion() > 5);
1489
1490
		return [
1491
			'album' => \array_map(function (Album $album) use ($genreKey, $includeArtists) {
1492
				$songCount = $this->trackBusinessLayer->countByAlbum($album->getId());
1493
				$name = $album->getNameString($this->l10n);
1494
				$nameParts = $this->prefixAndBaseName($name);
1495
				$apiAlbum = [
1496
					'id' => (string)$album->getId(),
1497
					'name' => $name,
1498
					'prefix' => $nameParts['prefix'],
1499
					'basename' => $nameParts['basename'],
1500
					'artist' => $this->renderAlbumOrArtistRef(
1501
						$album->getAlbumArtistId(),
1502
						$album->getAlbumArtistNameString($this->l10n)
1503
					),
1504
					'tracks' => $songCount, // TODO: this should contain objects if requested; in API5+, this never contains the count
1505
					'songcount' => $songCount,
1506
					'time' => $this->trackBusinessLayer->totalDurationOfAlbum($album->getId()),
1507
					'rating' => 0,
1508
					'year' => $album->yearToAPI(),
1509
					'art' => $this->createCoverUrl($album),
1510
					'preciserating' => 0,
1511
					'flag' => !empty($album->getStarred()),
1512
					$genreKey => \array_map(function ($genre) {
1513
						return [
1514
							'id' => (string)$genre->getId(),
1515
							'value' => $genre->getNameString($this->l10n),
1516
							'count' => 1
1517
						];
1518
					}, $album->getGenres() ?? [])
1519
				];
1520
				if ($includeArtists) {
1521
					$apiAlbum['artists'] = [$apiAlbum['artist']];
1522
				}
1523
1524
				return $apiAlbum;
1525
			}, $albums)
1526
		];
1527
	}
1528
1529
	/**
1530
	 * @param Track[] $tracks
1531
	 */
1532
	private function renderSongs(array $tracks) : array {
1533
		$userId = $this->session->getUserId();
1534
		$this->albumBusinessLayer->injectAlbumsToTracks($tracks, $userId);
1535
1536
		$createPlayUrl = function(Track $track) : string {
1537
			return $this->createAmpacheActionUrl('download', $track->getId());
1538
		};
1539
		$createImageUrl = function(Track $track) : string {
1540
			return $this->createCoverUrl($track->getAlbum());
0 ignored issues
show
Bug introduced by
It seems like $track->getAlbum() can also be of type null; however, parameter $entity of OCA\Music\Controller\Amp...oller::createCoverUrl() does only seem to accept OCA\Music\Db\Entity, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1540
			return $this->createCoverUrl(/** @scrutinizer ignore-type */ $track->getAlbum());
Loading history...
1541
		};
1542
		$renderRef = function(int $id, string $name) : array {
1543
			return $this->renderAlbumOrArtistRef($id, $name);
1544
		};
1545
		$genreKey = $this->genreKey();
1546
		// In APIv6 JSON format, there is a new property `artists` with an array value
1547
		$includeArtists = ($this->jsonMode && $this->apiMajorVersion() > 5);
1548
1549
		return [
1550
			'song' => Util::arrayMapMethod($tracks, 'toAmpacheApi', 
1551
				[$this->l10n, $createPlayUrl, $createImageUrl, $renderRef, $genreKey, $includeArtists])
1552
		];
1553
	}
1554
1555
	/**
1556
	 * @param Playlist[] $playlists
1557
	 */
1558
	private function renderPlaylists(array $playlists) : array {
1559
		$createImageUrl = function(Playlist $playlist) : string {
1560
			if ($playlist->getId() === self::ALL_TRACKS_PLAYLIST_ID) {
1561
				return '';
1562
			} else {
1563
				return $this->createCoverUrl($playlist);
1564
			}
1565
		};
1566
1567
		return [
1568
			'playlist' => Util::arrayMapMethod($playlists, 'toAmpacheApi', [$createImageUrl])
1569
		];
1570
	}
1571
1572
	/**
1573
	 * @param PodcastChannel[] $channels
1574
	 */
1575
	private function renderPodcastChannels(array $channels) : array {
1576
		return [
1577
			'podcast' => Util::arrayMapMethod($channels, 'toAmpacheApi')
1578
		];
1579
	}
1580
1581
	/**
1582
	 * @param PodcastEpisode[] $episodes
1583
	 */
1584
	private function renderPodcastEpisodes(array $episodes) : array {
1585
		return [
1586
			'podcast_episode' => Util::arrayMapMethod($episodes, 'toAmpacheApi')
1587
		];
1588
	}
1589
1590
	/**
1591
	 * @param RadioStation[] $stations
1592
	 */
1593
	private function renderLiveStreams(array $stations) : array {
1594
		return [
1595
			'live_stream' => Util::arrayMapMethod($stations, 'toAmpacheApi')
1596
		];
1597
	}
1598
1599
	/**
1600
	 * @param Genre[] $genres
1601
	 */
1602
	private function renderTags(array $genres) : array {
1603
		return [
1604
			'tag' => Util::arrayMapMethod($genres, 'toAmpacheApi', [$this->l10n])
1605
		];
1606
	}
1607
1608
	/**
1609
	 * @param Genre[] $genres
1610
	 */
1611
	private function renderGenres(array $genres) : array {
1612
		return [
1613
			'genre' => Util::arrayMapMethod($genres, 'toAmpacheApi', [$this->l10n])
1614
		];
1615
	}
1616
1617
	/**
1618
	 * @param Bookmark[] $bookmarks
1619
	 */
1620
	private function renderBookmarks(array $bookmarks) : array {
1621
		return [
1622
			'bookmark' => Util::arrayMapMethod($bookmarks, 'toAmpacheApi')
1623
		];
1624
	}
1625
1626
	/**
1627
	 * @param Track[] $tracks
1628
	 */
1629
	private function renderSongsIndex(array $tracks) : array {
1630
		return [
1631
			'song' => \array_map(function ($track) {
1632
				return [
1633
					'id' => (string)$track->getId(),
1634
					'title' => $track->getTitle(),
1635
					'name' => $track->getTitle(),
1636
					'artist' => $this->renderAlbumOrArtistRef($track->getArtistId(), $track->getArtistNameString($this->l10n)),
1637
					'album' => $this->renderAlbumOrArtistRef($track->getAlbumId(), $track->getAlbumNameString($this->l10n))
1638
				];
1639
			}, $tracks)
1640
		];
1641
	}
1642
1643
	/**
1644
	 * @param Album[] $albums
1645
	 */
1646
	private function renderAlbumsIndex(array $albums) : array {
1647
		return [
1648
			'album' => \array_map(function ($album) {
1649
				$name = $album->getNameString($this->l10n);
1650
				$nameParts = $this->prefixAndBaseName($name);
1651
1652
				return [
1653
					'id' => (string)$album->getId(),
1654
					'name' => $name,
1655
					'prefix' => $nameParts['prefix'],
1656
					'basename' => $nameParts['basename'],
1657
					'artist' => $this->renderAlbumOrArtistRef($album->getAlbumArtistId(), $album->getAlbumArtistNameString($this->l10n))
1658
				];
1659
			}, $albums)
1660
		];
1661
	}
1662
1663
	/**
1664
	 * @param Artist[] $artists
1665
	 */
1666
	private function renderArtistsIndex(array $artists) : array {
1667
		return [
1668
			'artist' => \array_map(function ($artist) {
1669
				$userId = $this->session->getUserId();
1670
				$albums = $this->albumBusinessLayer->findAllByArtist($artist->getId(), $userId);
1671
				$name = $artist->getNameString($this->l10n);
1672
				$nameParts = $this->prefixAndBaseName($name);
1673
1674
				return [
1675
					'id' => (string)$artist->getId(),
1676
					'name' => $name,
1677
					'prefix' => $nameParts['prefix'],
1678
					'basename' => $nameParts['basename'],
1679
					'album' => \array_map(function ($album) {
1680
						return $this->renderAlbumOrArtistRef($album->getId(), $album->getNameString($this->l10n));
1681
					}, $albums)
1682
				];
1683
			}, $artists)
1684
		];
1685
	}
1686
1687
	/**
1688
	 * @param Playlist[] $playlists
1689
	 */
1690
	private function renderPlaylistsIndex(array $playlists) : array {
1691
		return [
1692
			'playlist' => \array_map(function ($playlist) {
1693
				return [
1694
					'id' => (string)$playlist->getId(),
1695
					'name' => $playlist->getName(),
1696
					'playlisttrack' => $playlist->getTrackIdsAsArray()
1697
				];
1698
			}, $playlists)
1699
		];
1700
	}
1701
1702
	/**
1703
	 * @param PodcastChannel[] $channels
1704
	 */
1705
	private function renderPodcastChannelsIndex(array $channels) : array {
1706
		// The v4 API spec does not give any examples of this, and the v5 example is almost identical to the v4 "normal" result
1707
		return $this->renderPodcastChannels($channels);
1708
	}
1709
1710
	/**
1711
	 * @param PodcastEpisode[] $episodes
1712
	 */
1713
	private function renderPodcastEpisodesIndex(array $episodes) : array {
1714
		// The v4 API spec does not give any examples of this, and the v5 example is almost identical to the v4 "normal" result
1715
		return $this->renderPodcastEpisodes($episodes);
1716
	}
1717
1718
	/**
1719
	 * @param RadioStation[] $stations
1720
	 */
1721
	private function renderLiveStreamsIndex(array $stations) : array {
1722
		// The API spec gives no examples of this, but testing with Ampache demo server revealed that the format is indentical to the "full" format
1723
		return $this->renderLiveStreams($stations);
1724
	}
1725
1726
	/**
1727
	 * @param Entity[] $entities
1728
	 */
1729
	private function renderEntityIds(array $entities) : array {
1730
		return ['id' => Util::extractIds($entities)];
1731
	}
1732
1733
	/**
1734
	 * Array is considered to be "indexed" if its first element has numerical key.
1735
	 * Empty array is considered to be "indexed".
1736
	 */
1737
	private static function arrayIsIndexed(array $array) : bool {
1738
		\reset($array);
1739
		return empty($array) || \is_int(\key($array));
1740
	}
1741
1742
	/**
1743
	 * The JSON API has some asymmetries with the XML API. This function makes the needed
1744
	 * translations for the result content before it is converted into JSON.
1745
	 */
1746
	private function prepareResultForJsonApi(array $content) : array {
1747
		$apiVer = $this->apiMajorVersion();
1748
1749
		// Special handling is needed for responses returning an array of library entities,
1750
		// depending on the API version. In these cases, the outermost array is of associative
1751
		// type with a single value which is a non-associative array.
1752
		if (\count($content) === 1 && !self::arrayIsIndexed($content)
1753
				&& \is_array(\current($content)) && self::arrayIsIndexed(\current($content))) {
1754
			// In API versions < 5, the root node is an anonymous array. Unwrap the outermost array.
1755
			if ($apiVer < 5) {
1756
				$content = \array_pop($content);
1757
			}
1758
			// In later versions, the root object has a named array for plural actions (like "songs", "artists").
1759
			// For singular actions (like "song", "artist"), the root object contains directly the entity properties.
1760
			else {
1761
				$action = $this->request->getParam('action');
1762
				$plural = (\substr($action, -1) === 's' || $action === 'get_similar' || $action === 'advanced_search');
1763
1764
				// In APIv5, the action "album" is an excption, it is formatted as if it was a plural action.
1765
				// This outlier has been fixed in APIv6.
1766
				$api5albumOddity = ($apiVer === 5 && $action === 'album');
1767
1768
				if (!($plural  || $api5albumOddity)) {
1769
					$content = \array_pop($content);
1770
					$content = \array_pop($content);
1771
				}
1772
			}
1773
		}
1774
1775
		// In API versions < 6, all boolean valued properties should be converted to 0/1.
1776
		if ($apiVer < 6) {
1777
			Util::intCastArrayValues($content, 'is_bool');
1778
		}
1779
1780
		// The key 'value' has a special meaning on XML responses, as it makes the corresponding value
1781
		// to be treated as text content of the parent element. In the JSON API, these are mostly
1782
		// substituted with property 'name', but error responses use the property 'message', instead.
1783
		if (\array_key_exists('error', $content)) {
1784
			$content = Util::convertArrayKeys($content, ['value' => 'message']);
1785
		} else {
1786
			$content = Util::convertArrayKeys($content, ['value' => 'name']);
1787
		}
1788
		return $content;
1789
	}
1790
1791
	/**
1792
	 * The XML API has some asymmetries with the JSON API. This function makes the needed
1793
	 * translations for the result content before it is converted into XML.
1794
	 */
1795
	private function prepareResultForXmlApi(array $content) : array {
1796
		\reset($content);
1797
		$firstKey = \key($content);
1798
1799
		// all 'entity list' kind of responses shall have the (deprecated) total_count element
1800
		if ($firstKey == 'song' || $firstKey == 'album' || $firstKey == 'artist' || $firstKey == 'playlist'
1801
				|| $firstKey == 'tag' || $firstKey == 'genre' || $firstKey == 'podcast' || $firstKey == 'podcast_episode'
1802
				|| $firstKey == 'live_stream') {
1803
			$content = ['total_count' => \count($content[$firstKey])] + $content;
1804
		}
1805
1806
		// for some bizarre reason, the 'id' arrays have 'index' attributes in the XML format
1807
		if ($firstKey == 'id') {
1808
			$content['id'] = \array_map(function ($id, $index) {
1809
				return ['index' => $index, 'value' => $id];
1810
			}, $content['id'], \array_keys($content['id']));
1811
		}
1812
1813
		return ['root' => $content];
1814
	}
1815
1816
	private function genreKey() : string {
1817
		return ($this->apiMajorVersion() > 4) ? 'genre' : 'tag';
1818
	}
1819
1820
	private function apiMajorVersion() : int {
1821
		// During the handshake, we don't yet have a session but the requeted version may be in the request args
1822
		$verString = ($this->session !== null) 
1823
			? $this->session->getApiVersion()
1824
			: $this->request->getParam('version');
1825
		
1826
		if (\is_string($verString) && \strlen($verString)) {
1827
			$ver = (int)$verString[0];
1828
		} else {
1829
			// Default version is 6 unless otherwise defined in config.php
1830
			$ver = (int)$this->config->getSystemValue('music.ampache_api_default_ver', 6);
1831
		}
1832
1833
		// For now, we have three supported major versions. Major version 3 can be sufficiently supported
1834
		// with our "version 4" implementation.
1835
		return Util::limit($ver, 4, 6);
0 ignored issues
show
Bug Best Practice introduced by
The expression return OCA\Music\Utility\Util::limit($ver, 4, 6) could return the type null which is incompatible with the type-hinted return integer. Consider adding an additional type-check to rule them out.
Loading history...
1836
	}
1837
1838
	private function apiVersionString() : string {
1839
		switch ($this->apiMajorVersion()) {
1840
			case 4:		return self::API4_VERSION;
1841
			case 5:		return self::API5_VERSION;
1842
			case 6:		return self::API6_VERSION;
1843
			default:	throw new AmpacheException('Unexpected api major version', 500);
1844
		}
1845
	}
1846
1847
	private function mapApiV4ErrorToV5(int $code) : int {
1848
		switch ($code) {
1849
			case 400:	return 4710;	// bad request
1850
			case 401:	return 4701;	// invalid handshake
1851
			case 403:	return 4703;	// access denied
1852
			case 404:	return 4704;	// not found
1853
			case 405:	return 4705;	// missing
1854
			case 412:	return 4742;	// failed access check
1855
			case 501:	return 4700;	// access control not enabled
1856
			default:	return 5000;	// unexcpected (not part of the API spec)
1857
		}
1858
	}
1859
}
1860
1861
/**
1862
 * Adapter class which acts like the Playlist class for the purpose of
1863
 * AmpacheController::renderPlaylists but contains all the track of the user.
1864
 */
1865
class AmpacheController_AllTracksPlaylist extends Playlist {
1866
	private $trackBusinessLayer;
1867
	private $l10n;
1868
1869
	public function __construct(string $userId, TrackBusinessLayer $trackBusinessLayer, IL10N $l10n) {
1870
		$this->userId = $userId;
1871
		$this->trackBusinessLayer = $trackBusinessLayer;
1872
		$this->l10n = $l10n;
1873
	}
1874
1875
	public function getId() : int {
1876
		return AmpacheController::ALL_TRACKS_PLAYLIST_ID;
1877
	}
1878
1879
	public function getName() : string {
1880
		return $this->l10n->t('All tracks');
1881
	}
1882
1883
	public function getTrackCount() : int {
1884
		return $this->trackBusinessLayer->count($this->userId);
1885
	}
1886
}
1887