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

AmpacheController::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 44
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 1
eloc 21
c 2
b 0
f 0
nc 1
nop 22
dl 0
loc 44
rs 9.584

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(string $type, string $operator, int $limit, int $offset=0, bool $random=false) : array {
1041
		// get all the rule parameters as passed on the HTTP call
1042
		$rules = self::advSearchGetRuleParams($this->request->getParams());
1043
1044
		// apply some conversions on the rules
1045
		foreach ($rules as &$rule) {
1046
			$rule['rule'] = self::advSearchResolveRuleAlias($rule['rule']);
1047
			$rule['operator'] = self::advSearchInterpretOperator($rule['operator'], $rule['rule']);
1048
			$rule['input'] = self::advSearchConvertInput($rule['input'], $rule['rule']);
1049
		}
1050
1051
		try {
1052
			$businessLayer = $this->getBusinessLayer($type);
1053
			$userId = $this->session->getUserId();
1054
			$entities = $businessLayer->findAllAdvanced($operator, $rules, $random, $userId, $limit, $offset);
1055
		} catch (BusinessLayerException $e) {
1056
			throw new AmpacheException($e->getMessage(), 400);
1057
		}
1058
		
1059
		return $this->renderEntities($entities, $type);
1060
	}
1061
1062
	/**
1063
	 * @AmpacheAPI
1064
	 */
1065
	protected function flag(string $type, int $id, bool $flag) : array {
1066
		if (!\in_array($type, ['song', 'album', 'artist', 'podcast', 'podcast_episode'])) {
1067
			throw new AmpacheException("Unsupported type $type", 400);
1068
		}
1069
1070
		$userId = $this->session->getUserId();
1071
		$businessLayer = $this->getBusinessLayer($type);
1072
		if ($flag) {
1073
			$modifiedCount = $businessLayer->setStarred([$id], $userId);
1074
			$message = "flag ADDED to $type $id";
1075
		} else {
1076
			$modifiedCount = $businessLayer->unsetStarred([$id], $userId);
1077
			$message = "flag REMOVED from $type $id";
1078
		}
1079
1080
		if ($modifiedCount > 0) {
1081
			return ['success' => $message];
1082
		} else {
1083
			throw new AmpacheException("The $type $id was not found", 404);
1084
		}
1085
	}
1086
1087
	/**
1088
	 * @AmpacheAPI
1089
	 */
1090
	protected function record_play(int $id, ?int $date) : array {
1091
		$timeOfPlay = ($date === null) ? null : new \DateTime('@' . $date);
1092
		$this->trackBusinessLayer->recordTrackPlayed($id, $this->session->getUserId(), $timeOfPlay);
1093
		return ['success' => 'play recorded'];
1094
	}
1095
1096
	/**
1097
	 * @AmpacheAPI
1098
	 */
1099
	protected function download(int $id, string $type='song') : Response {
1100
		// request param `format` is ignored
1101
		$userId = $this->session->getUserId();
1102
1103
		if ($type === 'song') {
1104
			try {
1105
				$track = $this->trackBusinessLayer->find($id, $userId);
1106
			} catch (BusinessLayerException $e) {
1107
				return new ErrorResponse(Http::STATUS_NOT_FOUND, $e->getMessage());
1108
			}
1109
1110
			$file = $this->librarySettings->getFolder($userId)->getById($track->getFileId())[0] ?? null;
1111
1112
			if ($file instanceof \OCP\Files\File) {
1113
				return new FileStreamResponse($file);
1114
			} else {
1115
				return new ErrorResponse(Http::STATUS_NOT_FOUND);
1116
			}
1117
		} elseif ($type === 'podcast' || $type === 'podcast_episode') { // there's a difference between APIv4 and APIv5
1118
			$episode = $this->podcastEpisodeBusinessLayer->find($id, $userId);
1119
			return new RedirectResponse($episode->getStreamUrl());
1120
		} elseif ($type === 'playlist') {
1121
			$songIds = ($id === self::ALL_TRACKS_PLAYLIST_ID)
1122
				? $this->trackBusinessLayer->findAllIds($userId)
1123
				: $this->playlistBusinessLayer->find($id, $userId)->getTrackIdsAsArray();
1124
			if (empty($songIds)) {
1125
				throw new AmpacheException("The playlist $id is empty", 404);
1126
			} else {
1127
				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

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

1427
		if ($type === 'playlist' || $entity->/** @scrutinizer ignore-call */ getCoverFileId()) {
Loading history...
1428
			$id = $entity->getId();
1429
			$token = $this->imageService->getToken($type, $id, $this->session->getAmpacheUserId());
1430
			return $this->urlGenerator->linkToRouteAbsolute('music.ampacheImage.image') . "?object_type=$type&object_id=$id&token=$token";
1431
		} else {
1432
			return '';
1433
		}
1434
	}
1435
1436
	private static function indexIsWithinOffsetAndLimit(int $index, ?int $offset, ?int $limit) : bool {
1437
		$offset = \intval($offset); // missing offset is interpreted as 0-offset
1438
		return ($limit === null) || ($index >= $offset && $index < $offset + $limit);
1439
	}
1440
1441
	private function prefixAndBaseName(?string $name) : array {
1442
		$parts = ['prefix' => null, 'basename' => $name];
1443
1444
		if ($name !== null) {
1445
			foreach ($this->namePrefixes as $prefix) {
1446
				if (Util::startsWith($name, $prefix . ' ', /*ignoreCase=*/true)) {
1447
					$parts['prefix'] = $prefix;
1448
					$parts['basename'] = \substr($name, \strlen($prefix) + 1);
1449
					break;
1450
				}
1451
			}
1452
		}
1453
1454
		return $parts;
1455
	}
1456
1457
	private function renderAlbumOrArtistRef(int $id, string $name) : array {
1458
		if ($this->apiMajorVersion() > 5) {
1459
			return [
1460
				'id' => (string)$id,
1461
				'name' => $name,
1462
			] + $this->prefixAndBaseName($name);
1463
		} else {
1464
			return [
1465
				'id' => (string)$id,
1466
				'value' => $name
1467
			];
1468
		}
1469
	}
1470
1471
	/**
1472
	 * @param Artist[] $artists
1473
	 */
1474
	private function renderArtists(array $artists) : array {
1475
		$userId = $this->session->getUserId();
1476
		$genreMap = Util::createIdLookupTable($this->genreBusinessLayer->findAll($userId));
1477
		$genreKey = $this->genreKey();
1478
1479
		return [
1480
			'artist' => \array_map(function (Artist $artist) use ($userId, $genreMap, $genreKey) {
1481
				$albumCount = $this->albumBusinessLayer->countByArtist($artist->getId());
1482
				$songCount = $this->trackBusinessLayer->countByArtist($artist->getId());
1483
				$name = $artist->getNameString($this->l10n);
1484
				$nameParts = $this->prefixAndBaseName($name);
1485
				return [
1486
					'id' => (string)$artist->getId(),
1487
					'name' => $name,
1488
					'prefix' => $nameParts['prefix'],
1489
					'basename' => $nameParts['basename'],
1490
					'albums' => $albumCount, // TODO: this should contain objects if requested; in API5+, this never contains the count
1491
					'albumcount' => $albumCount,
1492
					'songs' => $songCount, // TODO: this should contain objects if requested; in API5+, this never contains the count
1493
					'songcount' => $songCount,
1494
					'time' => $this->trackBusinessLayer->totalDurationByArtist($artist->getId()),
1495
					'art' => $this->createCoverUrl($artist),
1496
					'rating' => 0,
1497
					'preciserating' => 0,
1498
					'flag' => !empty($artist->getStarred()),
1499
					$genreKey => \array_map(function ($genreId) use ($genreMap) {
1500
						return [
1501
							'id' => (string)$genreId,
1502
							'value' => $genreMap[$genreId]->getNameString($this->l10n),
1503
							'count' => 1
1504
						];
1505
					}, $this->trackBusinessLayer->getGenresByArtistId($artist->getId(), $userId))
1506
				];
1507
			}, $artists)
1508
		];
1509
	}
1510
1511
	/**
1512
	 * @param Album[] $albums
1513
	 */
1514
	private function renderAlbums(array $albums) : array {
1515
		$genreKey = $this->genreKey();
1516
		// In APIv6 JSON format, there is a new property `artists` with an array value
1517
		$includeArtists = ($this->jsonMode && $this->apiMajorVersion() > 5);
1518
1519
		return [
1520
			'album' => \array_map(function (Album $album) use ($genreKey, $includeArtists) {
1521
				$songCount = $this->trackBusinessLayer->countByAlbum($album->getId());
1522
				$name = $album->getNameString($this->l10n);
1523
				$nameParts = $this->prefixAndBaseName($name);
1524
				$apiAlbum = [
1525
					'id' => (string)$album->getId(),
1526
					'name' => $name,
1527
					'prefix' => $nameParts['prefix'],
1528
					'basename' => $nameParts['basename'],
1529
					'artist' => $this->renderAlbumOrArtistRef(
1530
						$album->getAlbumArtistId(),
1531
						$album->getAlbumArtistNameString($this->l10n)
1532
					),
1533
					'tracks' => $songCount, // TODO: this should contain objects if requested; in API5+, this never contains the count
1534
					'songcount' => $songCount,
1535
					'time' => $this->trackBusinessLayer->totalDurationOfAlbum($album->getId()),
1536
					'rating' => 0,
1537
					'year' => $album->yearToAPI(),
1538
					'art' => $this->createCoverUrl($album),
1539
					'preciserating' => 0,
1540
					'flag' => !empty($album->getStarred()),
1541
					$genreKey => \array_map(function ($genre) {
1542
						return [
1543
							'id' => (string)$genre->getId(),
1544
							'value' => $genre->getNameString($this->l10n),
1545
							'count' => 1
1546
						];
1547
					}, $album->getGenres() ?? [])
1548
				];
1549
				if ($includeArtists) {
1550
					$apiAlbum['artists'] = [$apiAlbum['artist']];
1551
				}
1552
1553
				return $apiAlbum;
1554
			}, $albums)
1555
		];
1556
	}
1557
1558
	/**
1559
	 * @param Track[] $tracks
1560
	 */
1561
	private function renderSongs(array $tracks) : array {
1562
		$userId = $this->session->getUserId();
1563
		$this->albumBusinessLayer->injectAlbumsToTracks($tracks, $userId);
1564
1565
		$createPlayUrl = function(Track $track) : string {
1566
			return $this->createAmpacheActionUrl('download', $track->getId());
1567
		};
1568
		$createImageUrl = function(Track $track) : string {
1569
			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

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