Passed
Pull Request — master (#1078)
by Pauli
05:40 queued 02:37
created

AmpacheController::playlist_generate()   B

Complexity

Conditions 8
Paths 40

Size

Total Lines 42
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 8
eloc 26
c 0
b 0
f 0
nc 40
nop 8
dl 0
loc 42
rs 8.4444

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, bool $top50=false) : array {
432
		$userId = $this->session->getUserId();
433
		if ($top50) {
434
			$tracks = $this->lastfmService->getTopTracks($filter, $userId, 50);
435
			$tracks = \array_slice($tracks, $offset, $limit);
436
		} else {
437
			$tracks = $this->trackBusinessLayer->findAllByArtist($filter, $userId, $limit, $offset);
438
		}
439
		return $this->renderSongs($tracks);
440
	}
441
442
	/**
443
	 * @AmpacheAPI
444
	 */
445
	protected function album_songs(int $filter, int $limit, int $offset=0) : array {
446
		$userId = $this->session->getUserId();
447
		$tracks = $this->trackBusinessLayer->findAllByAlbum($filter, $userId, null, $limit, $offset);
448
		return $this->renderSongs($tracks);
449
	}
450
451
	/**
452
	 * @AmpacheAPI
453
	 */
454
	protected function song(int $filter) : array {
455
		$userId = $this->session->getUserId();
456
		$track = $this->trackBusinessLayer->find($filter, $userId);
457
		$trackInArray = [$track];
458
		return $this->renderSongs($trackInArray);
459
	}
460
461
	/**
462
	 * @AmpacheAPI
463
	 */
464
	protected function songs(
465
			?string $filter, ?string $add, ?string $update,
466
			int $limit, int $offset=0, bool $exact=false) : array {
467
468
		$tracks = $this->findEntities($this->trackBusinessLayer, $filter, $exact, $limit, $offset, $add, $update);
469
		return $this->renderSongs($tracks);
470
	}
471
472
	/**
473
	 * @AmpacheAPI
474
	 */
475
	protected function search_songs(string $filter, int $limit, int $offset=0) : array {
476
		$userId = $this->session->getUserId();
477
		$tracks = $this->trackBusinessLayer->findAllByNameRecursive($filter, $userId, $limit, $offset);
478
		return $this->renderSongs($tracks);
479
	}
480
481
	/**
482
	 * @AmpacheAPI
483
	 */
484
	protected function albums(
485
			?string $filter, ?string $add, ?string $update,
486
			int $limit, int $offset=0, bool $exact=false) : array {
487
488
		$albums = $this->findEntities($this->albumBusinessLayer, $filter, $exact, $limit, $offset, $add, $update);
489
		return $this->renderAlbums($albums);
490
	}
491
492
	/**
493
	 * @AmpacheAPI
494
	 */
495
	protected function album(int $filter) : array {
496
		$userId = $this->session->getUserId();
497
		$album = $this->albumBusinessLayer->find($filter, $userId);
498
		return $this->renderAlbums([$album]);
499
	}
500
501
	/**
502
	 * @AmpacheAPI
503
	 */
504
	protected function get_similar(string $type, int $filter, int $limit, int $offset=0) : array {
505
		$userId = $this->session->getUserId();
506
		if ($type == 'artist') {
507
			$entities = $this->lastfmService->getSimilarArtists($filter, $userId);
508
		} elseif ($type == 'song') {
509
			$entities = $this->lastfmService->getSimilarTracks($filter, $userId);
510
		} else {
511
			throw new AmpacheException("Type '$type' is not supported", 400);
512
		}
513
		$entities = \array_slice($entities, $offset, $limit);
514
		return $this->renderEntitiesIndex($entities, $type);
515
	}
516
517
	/**
518
	 * @AmpacheAPI
519
	 */
520
	protected function playlists(
521
			?string $filter, ?string $add, ?string $update,
522
			int $limit, int $offset=0, bool $exact=false, int $hide_search=0) : array {
523
524
		$userId = $this->session->getUserId();
525
		$playlists = $this->findEntities($this->playlistBusinessLayer, $filter, $exact, $limit, $offset, $add, $update);
526
527
		// append "All tracks" if "seaches" are not forbidden, and not filtering by any criteria, and it is not off-limits
528
		$allTracksIndex = $this->playlistBusinessLayer->count($userId);
529
		if (!$hide_search && empty($filter) && empty($add) && empty($update)
530
				&& self::indexIsWithinOffsetAndLimit($allTracksIndex, $offset, $limit)) {
531
			$playlists[] = new AmpacheController_AllTracksPlaylist($userId, $this->trackBusinessLayer, $this->l10n);
532
		}
533
534
		return $this->renderPlaylists($playlists);
535
	}
536
537
	/**
538
	 * @AmpacheAPI
539
	 */
540
	protected function playlist(int $filter) : array {
541
		$userId = $this->session->getUserId();
542
		if ($filter == self::ALL_TRACKS_PLAYLIST_ID) {
543
			$playlist = new AmpacheController_AllTracksPlaylist($userId, $this->trackBusinessLayer, $this->l10n);
544
		} else {
545
			$playlist = $this->playlistBusinessLayer->find($filter, $userId);
546
		}
547
		return $this->renderPlaylists([$playlist]);
548
	}
549
550
	/**
551
	 * @AmpacheAPI
552
	 */
553
	protected function playlist_songs(int $filter, int $limit, int $offset=0) : array {
554
		$userId = $this->session->getUserId();
555
		if ($filter == self::ALL_TRACKS_PLAYLIST_ID) {
556
			$tracks = $this->trackBusinessLayer->findAll($userId, SortBy::Parent, $limit, $offset);
557
			foreach ($tracks as $index => &$track) {
558
				$track->setNumberOnPlaylist($index + 1);
559
			}
560
		} else {
561
			$tracks = $this->playlistBusinessLayer->getPlaylistTracks($filter, $userId, $limit, $offset);
562
		}
563
		return $this->renderSongs($tracks);
564
	}
565
566
	/**
567
	 * @AmpacheAPI
568
	 */
569
	protected function playlist_create(string $name) : array {
570
		$playlist = $this->playlistBusinessLayer->create($name, $this->session->getUserId());
571
		return $this->renderPlaylists([$playlist]);
572
	}
573
574
	/**
575
	 * @AmpacheAPI
576
	 *
577
	 * @param int $filter Playlist ID
578
	 * @param ?string $name New name for the playlist
579
	 * @param ?string $items Track IDs
580
	 * @param ?string $tracks 1-based indices of the tracks
581
	 */
582
	protected function playlist_edit(int $filter, ?string $name, ?string $items, ?string $tracks) : array {
583
		$edited = false;
584
		$userId = $this->session->getUserId();
585
		$playlist = $this->playlistBusinessLayer->find($filter, $userId);
586
587
		if (!empty($name)) {
588
			$playlist->setName($name);
589
			$edited = true;
590
		}
591
592
		$newTrackIds = Util::explode(',', $items);
593
		$newTrackOrdinals = Util::explode(',', $tracks);
594
595
		if (\count($newTrackIds) != \count($newTrackOrdinals)) {
596
			throw new AmpacheException("Arguments 'items' and 'tracks' must contain equal amount of elements", 400);
597
		} elseif (\count($newTrackIds) > 0) {
598
			$trackIds = $playlist->getTrackIdsAsArray();
599
600
			for ($i = 0, $count = \count($newTrackIds); $i < $count; ++$i) {
601
				$trackId = $newTrackIds[$i];
602
				if (!$this->trackBusinessLayer->exists($trackId, $userId)) {
603
					throw new AmpacheException("Invalid song ID $trackId", 404);
604
				}
605
				$trackIds[$newTrackOrdinals[$i]-1] = $trackId;
606
			}
607
608
			$playlist->setTrackIdsFromArray($trackIds);
609
			$edited = true;
610
		}
611
612
		if ($edited) {
613
			$this->playlistBusinessLayer->update($playlist);
614
			return ['success' => 'playlist changes saved'];
615
		} else {
616
			throw new AmpacheException('Nothing was changed', 400);
617
		}
618
	}
619
620
	/**
621
	 * @AmpacheAPI
622
	 */
623
	protected function playlist_delete(int $filter) : array {
624
		$this->playlistBusinessLayer->delete($filter, $this->session->getUserId());
625
		return ['success' => 'playlist deleted'];
626
	}
627
628
	/**
629
	 * @AmpacheAPI
630
	 */
631
	protected function playlist_add_song(int $filter, int $song, bool $check=false) : array {
632
		$userId = $this->session->getUserId();
633
		if (!$this->trackBusinessLayer->exists($song, $userId)) {
634
			throw new AmpacheException("Invalid song ID $song", 404);
635
		}
636
637
		$playlist = $this->playlistBusinessLayer->find($filter, $userId);
638
		$trackIds = $playlist->getTrackIdsAsArray();
639
640
		if ($check && \in_array($song, $trackIds)) {
641
			throw new AmpacheException("Can't add a duplicate item when check is enabled", 400);
642
		}
643
644
		$trackIds[] = $song;
645
		$playlist->setTrackIdsFromArray($trackIds);
646
		$this->playlistBusinessLayer->update($playlist);
647
		return ['success' => 'song added to playlist'];
648
	}
649
650
	/**
651
	 * @AmpacheAPI
652
	 *
653
	 * @param int $filter Playlist ID
654
	 * @param ?int $song Track ID
655
	 * @param ?int $track 1-based index of the track
656
	 * @param ?int $clear Value 1 erases all the songs from the playlist
657
	 */
658
	protected function playlist_remove_song(int $filter, ?int $song, ?int $track, ?int $clear) : array {
659
		$playlist = $this->playlistBusinessLayer->find($filter, $this->session->getUserId());
660
661
		if ($clear === 1) {
662
			$trackIds = [];
663
			$message = 'all songs removed from playlist';
664
		} elseif ($song !== null) {
665
			$trackIds = $playlist->getTrackIdsAsArray();
666
			if (!\in_array($song, $trackIds)) {
667
				throw new AmpacheException("Song $song not found in playlist", 404);
668
			}
669
			$trackIds = Util::arrayDiff($trackIds, [$song]);
670
			$message = 'song removed from playlist';
671
		} elseif ($track !== null) {
672
			$trackIds = $playlist->getTrackIdsAsArray();
673
			if ($track < 1 || $track > \count($trackIds)) {
674
				throw new AmpacheException("Track ordinal $track is out of bounds", 404);
675
			}
676
			unset($trackIds[$track-1]);
677
			$message = 'song removed from playlist';
678
		} else {
679
			throw new AmpacheException("One of the arguments 'clear', 'song', 'track' is required", 400);
680
		}
681
682
		$playlist->setTrackIdsFromArray($trackIds);
683
		$this->playlistBusinessLayer->update($playlist);
684
		return ['success' => $message];
685
	}
686
687
	/**
688
	 * @AmpacheAPI
689
	 */
690
	protected function playlist_generate(
691
			?string $filter, ?int $album, ?int $artist, ?int $flag,
692
			int $limit, int $offset=0, string $mode='random', string $format='song') : array {
693
694
		$tracks = $this->findEntities($this->trackBusinessLayer, $filter, false); // $limit and $offset are applied later
695
696
		// filter the found tracks according to the additional requirements
697
		if ($album !== null) {
698
			$tracks = \array_filter($tracks, function ($track) use ($album) {
699
				return ($track->getAlbumId() == $album);
700
			});
701
		}
702
		if ($artist !== null) {
703
			$tracks = \array_filter($tracks, function ($track) use ($artist) {
704
				return ($track->getArtistId() == $artist);
705
			});
706
		}
707
		if ($flag == 1) {
708
			$tracks = \array_filter($tracks, function ($track) {
709
				return ($track->getStarred() !== null);
710
			});
711
		}
712
		// After filtering, there may be "holes" between the array indices. Reindex the array.
713
		$tracks = \array_values($tracks);
714
715
		if ($mode == 'random') {
716
			$userId = $this->session->getUserId();
717
			$indices = $this->random->getIndices(\count($tracks), $offset, $limit, $userId, 'ampache_playlist_generate');
718
			$tracks = Util::arrayMultiGet($tracks, $indices);
719
		} else { // 'recent', 'forgotten', 'unplayed'
720
			throw new AmpacheException("Mode '$mode' is not supported", 400);
721
		}
722
723
		switch ($format) {
724
			case 'song':
725
				return $this->renderSongs($tracks);
726
			case 'index':
727
				return $this->renderSongsIndex($tracks);
728
			case 'id':
729
				return $this->renderEntityIds($tracks);
730
			default:
731
				throw new AmpacheException("Format '$format' is not supported", 400);
732
		}
733
	}
734
735
	/**
736
	 * @AmpacheAPI
737
	 */
738
	protected function podcasts(?string $filter, ?string $include, int $limit, int $offset=0, bool $exact=false) : array {
739
		$channels = $this->findEntities($this->podcastChannelBusinessLayer, $filter, $exact, $limit, $offset);
740
741
		if ($include === 'episodes') {
742
			$userId = $this->session->getUserId();
743
			$actuallyLimited = ($limit < $this->podcastChannelBusinessLayer->count($userId));
744
			$allChannelsIncluded = (!$filter && !$actuallyLimited && !$offset);
745
			$this->podcastService->injectEpisodes($channels, $userId, $allChannelsIncluded);
746
		}
747
748
		return $this->renderPodcastChannels($channels);
749
	}
750
751
	/**
752
	 * @AmpacheAPI
753
	 */
754
	protected function podcast(int $filter, ?string $include) : array {
755
		$userId = $this->session->getUserId();
756
		$channel = $this->podcastChannelBusinessLayer->find($filter, $userId);
757
758
		if ($include === 'episodes') {
759
			$channel->setEpisodes($this->podcastEpisodeBusinessLayer->findAllByChannel($filter, $userId));
760
		}
761
762
		return $this->renderPodcastChannels([$channel]);
763
	}
764
765
	/**
766
	 * @AmpacheAPI
767
	 */
768
	protected function podcast_create(string $url) : array {
769
		$userId = $this->session->getUserId();
770
		$result = $this->podcastService->subscribe($url, $userId);
771
772
		switch ($result['status']) {
773
			case PodcastService::STATUS_OK:
774
				return $this->renderPodcastChannels([$result['channel']]);
775
			case PodcastService::STATUS_INVALID_URL:
776
				throw new AmpacheException("Invalid URL $url", 400);
777
			case PodcastService::STATUS_INVALID_RSS:
778
				throw new AmpacheException("The document at URL $url is not a valid podcast RSS feed", 400);
779
			case PodcastService::STATUS_ALREADY_EXISTS:
780
				throw new AmpacheException('User already has this podcast channel subscribed', 400);
781
			default:
782
				throw new AmpacheException("Unexpected status code {$result['status']}", 400);
783
		}
784
	}
785
786
	/**
787
	 * @AmpacheAPI
788
	 */
789
	protected function podcast_delete(int $filter) : array {
790
		$userId = $this->session->getUserId();
791
		$status = $this->podcastService->unsubscribe($filter, $userId);
792
793
		switch ($status) {
794
			case PodcastService::STATUS_OK:
795
				return ['success' => 'podcast deleted'];
796
			case PodcastService::STATUS_NOT_FOUND:
797
				throw new AmpacheException('Channel to be deleted not found', 404);
798
			default:
799
				throw new AmpacheException("Unexpected status code $status", 400);
800
		}
801
	}
802
803
	/**
804
	 * @AmpacheAPI
805
	 */
806
	protected function podcast_episodes(int $filter, int $limit, int $offset=0) : array {
807
		$userId = $this->session->getUserId();
808
		$episodes = $this->podcastEpisodeBusinessLayer->findAllByChannel($filter, $userId, $limit, $offset);
809
		return $this->renderPodcastEpisodes($episodes);
810
	}
811
812
	/**
813
	 * @AmpacheAPI
814
	 */
815
	protected function podcast_episode(int $filter) : array {
816
		$userId = $this->session->getUserId();
817
		$episode = $this->podcastEpisodeBusinessLayer->find($filter, $userId);
818
		return $this->renderPodcastEpisodes([$episode]);
819
	}
820
821
	/**
822
	 * @AmpacheAPI
823
	 */
824
	protected function update_podcast(int $id) : array {
825
		$userId = $this->session->getUserId();
826
		$result = $this->podcastService->updateChannel($id, $userId);
827
828
		switch ($result['status']) {
829
			case PodcastService::STATUS_OK:
830
				$message = $result['updated'] ? 'channel was updated from the source' : 'no changes found';
831
				return ['success' => $message];
832
			case PodcastService::STATUS_NOT_FOUND:
833
				throw new AmpacheException('Channel to be updated not found', 404);
834
			case PodcastService::STATUS_INVALID_URL:
835
				throw new AmpacheException('failed to read from the channel URL', 400);
836
			case PodcastService::STATUS_INVALID_RSS:
837
				throw new AmpacheException('the document at the channel URL is not a valid podcast RSS feed', 400);
838
			default:
839
				throw new AmpacheException("Unexpected status code {$result['status']}", 400);
840
		}
841
	}
842
843
	/**
844
	 * @AmpacheAPI
845
	 */
846
	protected function live_streams(?string $filter, int $limit, int $offset=0, bool $exact=false) : array {
847
		$stations = $this->findEntities($this->radioStationBusinessLayer, $filter, $exact, $limit, $offset);
848
		return $this->renderLiveStreams($stations);
849
	}
850
851
	/**
852
	 * @AmpacheAPI
853
	 */
854
	protected function live_stream(int $filter) : array {
855
		$station = $this->radioStationBusinessLayer->find($filter, $this->session->getUserId());
856
		return $this->renderLiveStreams([$station]);
857
	}
858
859
	/**
860
	 * @AmpacheAPI
861
	 */
862
	protected function live_stream_create(string $name, string $url, ?string $site_url) : array {
863
		$station = $this->radioStationBusinessLayer->create($this->session->getUserId(), $name, $url, $site_url);
864
		return $this->renderLiveStreams([$station]);
865
	}
866
867
	/**
868
	 * @AmpacheAPI
869
	 */
870
	protected function live_stream_delete(int $filter) : array {
871
		$this->radioStationBusinessLayer->delete($filter, $this->session->getUserId());
872
		return ['success' => "Deleted live stream: $filter"];
873
	}
874
875
	/**
876
	 * @AmpacheAPI
877
	 */
878
	protected function live_stream_edit(int $filter, ?string $name, ?string $url, ?string $site_url) : array {
879
		$station = $this->radioStationBusinessLayer->find($filter, $this->session->getUserId());
880
881
		if ($name !== null) {
882
			$station->setName($name);
883
		}
884
		if ($url !== null) {
885
			$station->setStreamUrl($url);
886
		}
887
		if ($site_url !== null) {
888
			$station->setHomeUrl($site_url);
889
		}
890
		$station = $this->radioStationBusinessLayer->update($station);
891
892
		return $this->renderLiveStreams([$station]);
893
	}
894
895
	/**
896
	 * @AmpacheAPI
897
	 */
898
	protected function tags(?string $filter, int $limit, int $offset=0, bool $exact=false) : array {
899
		$genres = $this->findEntities($this->genreBusinessLayer, $filter, $exact, $limit, $offset);
900
		return $this->renderTags($genres);
901
	}
902
903
	/**
904
	 * @AmpacheAPI
905
	 */
906
	protected function tag(int $filter) : array {
907
		$userId = $this->session->getUserId();
908
		$genre = $this->genreBusinessLayer->find($filter, $userId);
909
		return $this->renderTags([$genre]);
910
	}
911
912
	/**
913
	 * @AmpacheAPI
914
	 */
915
	protected function tag_artists(int $filter, int $limit, int $offset=0) : array {
916
		$userId = $this->session->getUserId();
917
		$artists = $this->artistBusinessLayer->findAllByGenre($filter, $userId, $limit, $offset);
918
		return $this->renderArtists($artists);
919
	}
920
921
	/**
922
	 * @AmpacheAPI
923
	 */
924
	protected function tag_albums(int $filter, int $limit, int $offset=0) : array {
925
		$userId = $this->session->getUserId();
926
		$albums = $this->albumBusinessLayer->findAllByGenre($filter, $userId, $limit, $offset);
927
		return $this->renderAlbums($albums);
928
	}
929
930
	/**
931
	 * @AmpacheAPI
932
	 */
933
	protected function tag_songs(int $filter, int $limit, int $offset=0) : array {
934
		$userId = $this->session->getUserId();
935
		$tracks = $this->trackBusinessLayer->findAllByGenre($filter, $userId, $limit, $offset);
936
		return $this->renderSongs($tracks);
937
	}
938
939
	/**
940
	 * @AmpacheAPI
941
	 */
942
	protected function genres(?string $filter, int $limit, int $offset=0, bool $exact=false) : array {
943
		$genres = $this->findEntities($this->genreBusinessLayer, $filter, $exact, $limit, $offset);
944
		return $this->renderGenres($genres);
945
	}
946
947
	/**
948
	 * @AmpacheAPI
949
	 */
950
	protected function genre(int $filter) : array {
951
		$userId = $this->session->getUserId();
952
		$genre = $this->genreBusinessLayer->find($filter, $userId);
953
		return $this->renderGenres([$genre]);
954
	}
955
956
	/**
957
	 * @AmpacheAPI
958
	 */
959
	protected function genre_artists(?int $filter, int $limit, int $offset=0) : array {
960
		if ($filter === null) {
961
			return $this->artists(null, null, null, $limit, $offset);
962
		} else {
963
			return $this->tag_artists($filter, $limit, $offset);
964
		}
965
	}
966
967
	/**
968
	 * @AmpacheAPI
969
	 */
970
	protected function genre_albums(?int $filter, int $limit, int $offset=0) : array {
971
		if ($filter === null) {
972
			return $this->albums(null, null, null, $limit, $offset);
973
		} else {
974
			return $this->tag_albums($filter, $limit, $offset);
975
		}
976
	}
977
978
	/**
979
	 * @AmpacheAPI
980
	 */
981
	protected function genre_songs(?int $filter, int $limit, int $offset=0) : array {
982
		if ($filter === null) {
983
			return $this->songs(null, null, null, $limit, $offset);
984
		} else {
985
			return $this->tag_songs($filter, $limit, $offset);
986
		}
987
	}
988
989
	/**
990
	 * @AmpacheAPI
991
	 */
992
	protected function bookmarks() : array {
993
		$bookmarks = $this->bookmarkBusinessLayer->findAll($this->session->getUserId());
994
		return $this->renderBookmarks($bookmarks);
995
	}
996
997
	/**
998
	 * @AmpacheAPI
999
	 */
1000
	protected function get_bookmark(int $filter, string $type) : array {
1001
		$entryType = self::mapBookmarkType($type);
1002
		$bookmark = $this->bookmarkBusinessLayer->findByEntry($entryType, $filter, $this->session->getUserId());
1003
		return $this->renderBookmarks([$bookmark]);
1004
	}
1005
1006
	/**
1007
	 * @AmpacheAPI
1008
	 */
1009
	protected function bookmark_create(int $filter, string $type, int $position, string $client='AmpacheAPI') : array {
1010
		// Note: the optional argument 'date' is not supported and is disregarded
1011
		$entryType = self::mapBookmarkType($type);
1012
		$position *= 1000; // seconds to milliseconds
1013
		$bookmark = $this->bookmarkBusinessLayer->addOrUpdate($this->session->getUserId(), $entryType, $filter, $position, $client);
1014
		return $this->renderBookmarks([$bookmark]);
1015
	}
1016
1017
	/**
1018
	 * @AmpacheAPI
1019
	 */
1020
	protected function bookmark_edit(int $filter, string $type, int $position, ?string $client) : array {
1021
		// Note: the optional argument 'date' is not supported and is disregarded
1022
		$entryType = self::mapBookmarkType($type);
1023
		$bookmark = $this->bookmarkBusinessLayer->findByEntry($entryType, $filter, $this->session->getUserId());
1024
		$bookmark->setPosition($position * 1000); // seconds to milliseconds
1025
		if ($client !== null) {
1026
			$bookmark->setComment($client);
1027
		}
1028
		$bookmark = $this->bookmarkBusinessLayer->update($bookmark);
1029
		return $this->renderBookmarks([$bookmark]);
1030
	}
1031
1032
	/**
1033
	 * @AmpacheAPI
1034
	 */
1035
	protected function bookmark_delete(int $filter, string $type) : array {
1036
		$entryType = self::mapBookmarkType($type);
1037
		$bookmark = $this->bookmarkBusinessLayer->findByEntry($entryType, $filter, $this->session->getUserId());
1038
		$this->bookmarkBusinessLayer->delete($bookmark->getId(), $bookmark->getUserId());
1039
		return ['success' => "Deleted Bookmark: $type $filter"];
1040
	}
1041
1042
	/**
1043
	 * @AmpacheAPI
1044
	 */
1045
	protected function advanced_search(string $type, string $operator, int $limit, int $offset=0, bool $random=false) : array {
1046
		// get all the rule parameters as passed on the HTTP call
1047
		$rules = self::advSearchGetRuleParams($this->request->getParams());
1048
1049
		// apply some conversions on the rules
1050
		foreach ($rules as &$rule) {
1051
			$rule['rule'] = self::advSearchResolveRuleAlias($rule['rule']);
1052
			$rule['operator'] = self::advSearchInterpretOperator($rule['operator'], $rule['rule']);
1053
			$rule['input'] = self::advSearchConvertInput($rule['input'], $rule['rule']);
1054
		}
1055
1056
		try {
1057
			$businessLayer = $this->getBusinessLayer($type);
1058
			$userId = $this->session->getUserId();
1059
			$entities = $businessLayer->findAllAdvanced($operator, $rules, $random, $userId, $limit, $offset);
1060
		} catch (BusinessLayerException $e) {
1061
			throw new AmpacheException($e->getMessage(), 400);
1062
		}
1063
		
1064
		return $this->renderEntities($entities, $type);
1065
	}
1066
1067
	/**
1068
	 * @AmpacheAPI
1069
	 */
1070
	protected function flag(string $type, int $id, bool $flag) : array {
1071
		if (!\in_array($type, ['song', 'album', 'artist', 'podcast', 'podcast_episode'])) {
1072
			throw new AmpacheException("Unsupported type $type", 400);
1073
		}
1074
1075
		$userId = $this->session->getUserId();
1076
		$businessLayer = $this->getBusinessLayer($type);
1077
		if ($flag) {
1078
			$modifiedCount = $businessLayer->setStarred([$id], $userId);
1079
			$message = "flag ADDED to $type $id";
1080
		} else {
1081
			$modifiedCount = $businessLayer->unsetStarred([$id], $userId);
1082
			$message = "flag REMOVED from $type $id";
1083
		}
1084
1085
		if ($modifiedCount > 0) {
1086
			return ['success' => $message];
1087
		} else {
1088
			throw new AmpacheException("The $type $id was not found", 404);
1089
		}
1090
	}
1091
1092
	/**
1093
	 * @AmpacheAPI
1094
	 */
1095
	protected function record_play(int $id, ?int $date) : array {
1096
		$timeOfPlay = ($date === null) ? null : new \DateTime('@' . $date);
1097
		$this->trackBusinessLayer->recordTrackPlayed($id, $this->session->getUserId(), $timeOfPlay);
1098
		return ['success' => 'play recorded'];
1099
	}
1100
1101
	/**
1102
	 * @AmpacheAPI
1103
	 */
1104
	protected function download(int $id, string $type='song') : Response {
1105
		// request param `format` is ignored
1106
		$userId = $this->session->getUserId();
1107
1108
		if ($type === 'song') {
1109
			try {
1110
				$track = $this->trackBusinessLayer->find($id, $userId);
1111
			} catch (BusinessLayerException $e) {
1112
				return new ErrorResponse(Http::STATUS_NOT_FOUND, $e->getMessage());
1113
			}
1114
1115
			$file = $this->librarySettings->getFolder($userId)->getById($track->getFileId())[0] ?? null;
1116
1117
			if ($file instanceof \OCP\Files\File) {
1118
				return new FileStreamResponse($file);
1119
			} else {
1120
				return new ErrorResponse(Http::STATUS_NOT_FOUND);
1121
			}
1122
		} elseif ($type === 'podcast' || $type === 'podcast_episode') { // there's a difference between APIv4 and APIv5
1123
			$episode = $this->podcastEpisodeBusinessLayer->find($id, $userId);
1124
			return new RedirectResponse($episode->getStreamUrl());
1125
		} elseif ($type === 'playlist') {
1126
			$songIds = ($id === self::ALL_TRACKS_PLAYLIST_ID)
1127
				? $this->trackBusinessLayer->findAllIds($userId)
1128
				: $this->playlistBusinessLayer->find($id, $userId)->getTrackIdsAsArray();
1129
			if (empty($songIds)) {
1130
				throw new AmpacheException("The playlist $id is empty", 404);
1131
			} else {
1132
				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

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

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

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