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

1114
		$entity->/** @scrutinizer ignore-call */ 
1115
           setRating($rating);
Loading history...
Bug introduced by
The method setRating() does not exist on OCA\Music\Db\Bookmark. 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

1114
		$entity->/** @scrutinizer ignore-call */ 
1115
           setRating($rating);
Loading history...
Bug introduced by
The method setRating() does not exist on OCA\Music\Db\Genre. 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

1114
		$entity->/** @scrutinizer ignore-call */ 
1115
           setRating($rating);
Loading history...
1115
		$businessLayer->update($entity);
1116
1117
		return ['success' => "rating set to $rating for $type $id"];
1118
	}
1119
1120
	/**
1121
	 * @AmpacheAPI
1122
	 */
1123
	protected function record_play(int $id, ?int $date) : array {
1124
		$timeOfPlay = ($date === null) ? null : new \DateTime('@' . $date);
1125
		$this->trackBusinessLayer->recordTrackPlayed($id, $this->session->getUserId(), $timeOfPlay);
1126
		return ['success' => 'play recorded'];
1127
	}
1128
1129
	/**
1130
	 * @AmpacheAPI
1131
	 */
1132
	protected function download(int $id, string $type='song') : Response {
1133
		// request param `format` is ignored
1134
		$userId = $this->session->getUserId();
1135
1136
		if ($type === 'song') {
1137
			try {
1138
				$track = $this->trackBusinessLayer->find($id, $userId);
1139
			} catch (BusinessLayerException $e) {
1140
				return new ErrorResponse(Http::STATUS_NOT_FOUND, $e->getMessage());
1141
			}
1142
1143
			$file = $this->librarySettings->getFolder($userId)->getById($track->getFileId())[0] ?? null;
1144
1145
			if ($file instanceof \OCP\Files\File) {
1146
				return new FileStreamResponse($file);
1147
			} else {
1148
				return new ErrorResponse(Http::STATUS_NOT_FOUND);
1149
			}
1150
		} elseif ($type === 'podcast' || $type === 'podcast_episode') { // there's a difference between APIv4 and APIv5
1151
			$episode = $this->podcastEpisodeBusinessLayer->find($id, $userId);
1152
			return new RedirectResponse($episode->getStreamUrl());
1153
		} elseif ($type === 'playlist') {
1154
			$songIds = ($id === self::ALL_TRACKS_PLAYLIST_ID)
1155
				? $this->trackBusinessLayer->findAllIds($userId)
1156
				: $this->playlistBusinessLayer->find($id, $userId)->getTrackIdsAsArray();
1157
			if (empty($songIds)) {
1158
				throw new AmpacheException("The playlist $id is empty", 404);
1159
			} else {
1160
				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

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

1474
		if ($type === 'playlist' || $entity->/** @scrutinizer ignore-call */ getCoverFileId()) {
Loading history...
1475
			$id = $entity->getId();
1476
			$token = $this->imageService->getToken($type, $id, $this->session->getAmpacheUserId());
1477
			return $this->urlGenerator->linkToRouteAbsolute('music.ampacheImage.image') . "?object_type=$type&object_id=$id&token=$token";
1478
		} else {
1479
			return '';
1480
		}
1481
	}
1482
1483
	private static function indexIsWithinOffsetAndLimit(int $index, ?int $offset, ?int $limit) : bool {
1484
		$offset = \intval($offset); // missing offset is interpreted as 0-offset
1485
		return ($limit === null) || ($index >= $offset && $index < $offset + $limit);
1486
	}
1487
1488
	private function prefixAndBaseName(?string $name) : array {
1489
		$parts = ['prefix' => null, 'basename' => $name];
1490
1491
		if ($name !== null) {
1492
			foreach ($this->namePrefixes as $prefix) {
1493
				if (Util::startsWith($name, $prefix . ' ', /*ignoreCase=*/true)) {
1494
					$parts['prefix'] = $prefix;
1495
					$parts['basename'] = \substr($name, \strlen($prefix) + 1);
1496
					break;
1497
				}
1498
			}
1499
		}
1500
1501
		return $parts;
1502
	}
1503
1504
	private function renderAlbumOrArtistRef(int $id, string $name) : array {
1505
		if ($this->apiMajorVersion() > 5) {
1506
			return [
1507
				'id' => (string)$id,
1508
				'name' => $name,
1509
			] + $this->prefixAndBaseName($name);
1510
		} else {
1511
			return [
1512
				'id' => (string)$id,
1513
				'value' => $name
1514
			];
1515
		}
1516
	}
1517
1518
	/**
1519
	 * @param Artist[] $artists
1520
	 */
1521
	private function renderArtists(array $artists) : array {
1522
		$userId = $this->session->getUserId();
1523
		$genreMap = Util::createIdLookupTable($this->genreBusinessLayer->findAll($userId));
1524
		$genreKey = $this->genreKey();
1525
1526
		return [
1527
			'artist' => \array_map(function (Artist $artist) use ($userId, $genreMap, $genreKey) {
1528
				$albumCount = $this->albumBusinessLayer->countByArtist($artist->getId());
1529
				$songCount = $this->trackBusinessLayer->countByArtist($artist->getId());
1530
				$name = $artist->getNameString($this->l10n);
1531
				$nameParts = $this->prefixAndBaseName($name);
1532
				return [
1533
					'id' => (string)$artist->getId(),
1534
					'name' => $name,
1535
					'prefix' => $nameParts['prefix'],
1536
					'basename' => $nameParts['basename'],
1537
					'albums' => $albumCount, // TODO: this should contain objects if requested; in API5+, this never contains the count
1538
					'albumcount' => $albumCount,
1539
					'songs' => $songCount, // TODO: this should contain objects if requested; in API5+, this never contains the count
1540
					'songcount' => $songCount,
1541
					'time' => $this->trackBusinessLayer->totalDurationByArtist($artist->getId()),
1542
					'art' => $this->createCoverUrl($artist),
1543
					'rating' => $artist->getRating() ?? 0,
1544
					'preciserating' => $artist->getRating() ?? 0,
1545
					'flag' => !empty($artist->getStarred()),
1546
					$genreKey => \array_map(function ($genreId) use ($genreMap) {
1547
						return [
1548
							'id' => (string)$genreId,
1549
							'value' => $genreMap[$genreId]->getNameString($this->l10n),
1550
							'count' => 1
1551
						];
1552
					}, $this->trackBusinessLayer->getGenresByArtistId($artist->getId(), $userId))
1553
				];
1554
			}, $artists)
1555
		];
1556
	}
1557
1558
	/**
1559
	 * @param Album[] $albums
1560
	 */
1561
	private function renderAlbums(array $albums) : array {
1562
		$genreKey = $this->genreKey();
1563
		// In APIv6 JSON format, there is a new property `artists` with an array value
1564
		$includeArtists = ($this->jsonMode && $this->apiMajorVersion() > 5);
1565
1566
		return [
1567
			'album' => \array_map(function (Album $album) use ($genreKey, $includeArtists) {
1568
				$songCount = $this->trackBusinessLayer->countByAlbum($album->getId());
1569
				$name = $album->getNameString($this->l10n);
1570
				$nameParts = $this->prefixAndBaseName($name);
1571
				$apiAlbum = [
1572
					'id' => (string)$album->getId(),
1573
					'name' => $name,
1574
					'prefix' => $nameParts['prefix'],
1575
					'basename' => $nameParts['basename'],
1576
					'artist' => $this->renderAlbumOrArtistRef(
1577
						$album->getAlbumArtistId(),
1578
						$album->getAlbumArtistNameString($this->l10n)
1579
					),
1580
					'tracks' => $songCount, // TODO: this should contain objects if requested; in API5+, this never contains the count
1581
					'songcount' => $songCount,
1582
					'time' => $this->trackBusinessLayer->totalDurationOfAlbum($album->getId()),
1583
					'rating' => $album->getRating() ?? 0,
1584
					'preciserating' => $album->getRating() ?? 0,
1585
					'year' => $album->yearToAPI(),
1586
					'art' => $this->createCoverUrl($album),
1587
					'flag' => !empty($album->getStarred()),
1588
					$genreKey => \array_map(function ($genre) {
1589
						return [
1590
							'id' => (string)$genre->getId(),
1591
							'value' => $genre->getNameString($this->l10n),
1592
							'count' => 1
1593
						];
1594
					}, $album->getGenres() ?? [])
1595
				];
1596
				if ($includeArtists) {
1597
					$apiAlbum['artists'] = [$apiAlbum['artist']];
1598
				}
1599
1600
				return $apiAlbum;
1601
			}, $albums)
1602
		];
1603
	}
1604
1605
	/**
1606
	 * @param Track[] $tracks
1607
	 */
1608
	private function renderSongs(array $tracks) : array {
1609
		$userId = $this->session->getUserId();
1610
		$this->albumBusinessLayer->injectAlbumsToTracks($tracks, $userId);
1611
1612
		$createPlayUrl = function(Track $track) : string {
1613
			return $this->createAmpacheActionUrl('download', $track->getId());
1614
		};
1615
		$createImageUrl = function(Track $track) : string {
1616
			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

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