Passed
Push — feature/909_Ampache_API_improv... ( 673c0e...814067 )
by Pauli
02:45
created

AmpacheController::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 44
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

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

How to fix   Many Parameters   

Many Parameters

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

There are several approaches to avoid long parameter lists:

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

1161
			$entity->/** @scrutinizer ignore-call */ 
1162
            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

1161
			$entity->/** @scrutinizer ignore-call */ 
1162
            setRating($rating);
Loading history...
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

1161
			$entity->/** @scrutinizer ignore-call */ 
1162
            setRating($rating);
Loading history...
1162
			$businessLayer->update($entity);
1163
		} else {
1164
			throw new AmpacheException("Unsupported type $type", 400);
1165
		}
1166
1167
		return ['success' => "rating set to $rating for $type $id"];
1168
	}
1169
1170
	/**
1171
	 * @AmpacheAPI
1172
	 */
1173
	protected function record_play(int $id, ?int $date) : array {
1174
		$timeOfPlay = ($date === null) ? null : new \DateTime('@' . $date);
1175
		$this->trackBusinessLayer->recordTrackPlayed($id, $this->session->getUserId(), $timeOfPlay);
1176
		return ['success' => 'play recorded'];
1177
	}
1178
1179
	/**
1180
	 * @AmpacheAPI
1181
	 */
1182
	protected function user_preferences() : array {
1183
		return ['user_preference' => AmpachePreferences::getAll()];
1184
	}
1185
1186
	/**
1187
	 * @AmpacheAPI
1188
	 */
1189
	protected function user_preference(string $filter) : array {
1190
		$pref = AmpachePreferences::get($filter);
1191
		if ($pref === null) {
1192
			throw new AmpacheException("Not Found: $filter", 400);
1193
		} else {
1194
			return ['user_preference' => [$pref]];
1195
		}
1196
	}
1197
1198
	/**
1199
	 * @AmpacheAPI
1200
	 */
1201
	protected function download(int $id, string $type='song') : Response {
1202
		// request param `format` is ignored
1203
		$userId = $this->session->getUserId();
1204
1205
		if ($type === 'song') {
1206
			try {
1207
				$track = $this->trackBusinessLayer->find($id, $userId);
1208
			} catch (BusinessLayerException $e) {
1209
				return new ErrorResponse(Http::STATUS_NOT_FOUND, $e->getMessage());
1210
			}
1211
1212
			$file = $this->librarySettings->getFolder($userId)->getById($track->getFileId())[0] ?? null;
1213
1214
			if ($file instanceof \OCP\Files\File) {
1215
				return new FileStreamResponse($file);
1216
			} else {
1217
				return new ErrorResponse(Http::STATUS_NOT_FOUND);
1218
			}
1219
		} elseif ($type === 'podcast' || $type === 'podcast_episode') { // there's a difference between APIv4 and APIv5
1220
			$episode = $this->podcastEpisodeBusinessLayer->find($id, $userId);
1221
			return new RedirectResponse($episode->getStreamUrl());
1222
		} elseif ($type === 'playlist') {
1223
			$songIds = ($id === self::ALL_TRACKS_PLAYLIST_ID)
1224
				? $this->trackBusinessLayer->findAllIds($userId)
1225
				: $this->playlistBusinessLayer->find($id, $userId)->getTrackIdsAsArray();
1226
			if (empty($songIds)) {
1227
				throw new AmpacheException("The playlist $id is empty", 404);
1228
			} else {
1229
				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

1229
				return $this->download(/** @scrutinizer ignore-type */ Random::pickItem($songIds));
Loading history...
1230
			}
1231
		} else {
1232
			throw new AmpacheException("Unsupported type '$type'", 400);
1233
		}
1234
	}
1235
1236
	/**
1237
	 * @AmpacheAPI
1238
	 */
1239
	protected function stream(int $id, ?int $offset, string $type='song') : Response {
1240
		// request params `bitrate`, `format`, and `length` are ignored
1241
1242
		// This is just a dummy implementation. We don't support transcoding or streaming
1243
		// from a time offset.
1244
		// All the other unsupported arguments are just ignored, but a request with an offset
1245
		// is responded with an error. This is becuase the client would probably work in an
1246
		// unexpected way if it thinks it's streaming from offset but actually it is streaming
1247
		// from the beginning of the file. Returning an error gives the client a chance to fallback
1248
		// to other methods of seeking.
1249
		if ($offset !== null) {
1250
			throw new AmpacheException('Streaming with time offset is not supported', 400);
1251
		}
1252
1253
		return $this->download($id, $type);
1254
	}
1255
1256
	/**
1257
	 * @AmpacheAPI
1258
	 */
1259
	protected function get_art(string $type, int $id) : Response {
1260
		if (!\in_array($type, ['song', 'album', 'artist', 'podcast', 'playlist'])) {
1261
			throw new AmpacheException("Unsupported type $type", 400);
1262
		}
1263
1264
		if ($type === 'song') {
1265
			// map song to its parent album
1266
			$id = $this->trackBusinessLayer->find($id, $this->session->getUserId())->getAlbumId();
1267
			$type = 'album';
1268
		}
1269
1270
		return $this->getCover($id, $this->getBusinessLayer($type));
1271
	}
1272
1273
	/********************
1274
	 * Helper functions *
1275
	 ********************/
1276
1277
	private function getBusinessLayer(string $type) : BusinessLayer {
1278
		switch ($type) {
1279
			case 'song':			return $this->trackBusinessLayer;
1280
			case 'album':			return $this->albumBusinessLayer;
1281
			case 'artist':			return $this->artistBusinessLayer;
1282
			case 'playlist':		return $this->playlistBusinessLayer;
1283
			case 'podcast':			return $this->podcastChannelBusinessLayer;
1284
			case 'podcast_episode':	return $this->podcastEpisodeBusinessLayer;
1285
			case 'live_stream':		return $this->radioStationBusinessLayer;
1286
			case 'tag':				return $this->genreBusinessLayer;
1287
			case 'genre':			return $this->genreBusinessLayer;
1288
			case 'bookmark':		return $this->bookmarkBusinessLayer;
1289
			default:				throw new AmpacheException("Unsupported type $type", 400);
1290
		}
1291
	}
1292
1293
	private function renderEntities(array $entities, string $type) : array {
1294
		switch ($type) {
1295
			case 'song':			return $this->renderSongs($entities);
1296
			case 'album':			return $this->renderAlbums($entities);
1297
			case 'artist':			return $this->renderArtists($entities);
1298
			case 'playlist':		return $this->renderPlaylists($entities);
1299
			case 'podcast':			return $this->renderPodcastChannels($entities);
1300
			case 'podcast_episode':	return $this->renderPodcastEpisodes($entities);
1301
			case 'live_stream':		return $this->renderLiveStreams($entities);
1302
			case 'tag':				return $this->renderTags($entities);
1303
			case 'genre':			return $this->renderGenres($entities);
1304
			case 'bookmark':		return $this->renderBookmarks($entities);
1305
			default:				throw new AmpacheException("Unsupported type $type", 400);
1306
		}
1307
	}
1308
1309
	private function renderEntitiesIndex($entities, $type) : array {
1310
		switch ($type) {
1311
			case 'song':			return $this->renderSongsIndex($entities);
1312
			case 'album':			return $this->renderAlbumsIndex($entities);
1313
			case 'artist':			return $this->renderArtistsIndex($entities);
1314
			case 'playlist':		return $this->renderPlaylistsIndex($entities);
1315
			case 'podcast':			return $this->renderPodcastChannelsIndex($entities);
1316
			case 'podcast_episode':	return $this->renderPodcastEpisodesIndex($entities);
1317
			case 'live_stream':		return $this->renderLiveStreamsIndex($entities);
1318
			case 'tag':				return $this->renderTags($entities); // not part of the API spec
1319
			case 'genre':			return $this->renderGenres($entities); // not part of the API spec
1320
			case 'bookmark':		return $this->renderBookmarks($entities); // not part of the API spec
1321
			default:				throw new AmpacheException("Unsupported type $type", 400);
1322
		}
1323
	}
1324
1325
	private static function mapBookmarkType(string $ampacheType) : int {
1326
		switch ($ampacheType) {
1327
			case 'song':			return Bookmark::TYPE_TRACK;
1328
			case 'podcast_episode':	return Bookmark::TYPE_PODCAST_EPISODE;
1329
			default:				throw new AmpacheException("Unsupported type $ampacheType", 400);
1330
		}
1331
	}
1332
1333
	private static function advSearchResolveRuleAlias(string $rule) : string {
1334
		switch ($rule) {
1335
			case 'name':					return 'title';
1336
			case 'song_title':				return 'song';
1337
			case 'album_title':				return 'album';
1338
			case 'artist_title':			return 'artist';
1339
			case 'podcast_title':			return 'podcast';
1340
			case 'podcast_episode_title':	return 'podcast_episode';
1341
			case 'album_artist_title':		return 'album_artist';
1342
			case 'song_artist_title':		return 'song_artist';
1343
			case 'tag':						return 'genre';
1344
			case 'song_tag':				return 'song_genre';
1345
			case 'album_tag':				return 'album_genre';
1346
			case 'artist_tag':				return 'artist_genre';
1347
			case 'no_tag':					return 'no_genre';
1348
			default:						return $rule;
1349
		}
1350
	}
1351
1352
	private static function advSearchGetRuleParams(array $urlParams) : array {
1353
		$rules = [];
1354
1355
		// read and organize the rule parameters
1356
		foreach ($urlParams as $key => $value) {
1357
			$parts = \explode('_', $key, 3);
1358
			if ($parts[0] == 'rule' && \count($parts) > 1) {
1359
				if (\count($parts) == 2) {
1360
					$rules[$parts[1]]['rule'] = $value;
1361
				} elseif ($parts[2] == 'operator') {
1362
					$rules[$parts[1]]['operator'] = (int)$value;
1363
				} elseif ($parts[2] == 'input') {
1364
					$rules[$parts[1]]['input'] = $value;
1365
				}
1366
			}
1367
		}
1368
1369
		// validate the rule parameters
1370
		if (\count($rules) === 0) {
1371
			throw new AmpacheException('At least one rule must be given', 400);
1372
		}
1373
		foreach ($rules as $rule) {
1374
			if (\count($rule) != 3) {
1375
				throw new AmpacheException('All rules must be given as triplet "rule_N", "rule_N_operator", "rule_N_input"', 400);
1376
			}
1377
		}
1378
1379
		return $rules;
1380
	}
1381
1382
	// NOTE: alias rule names should be resolved to their base form before calling this
1383
	private static function advSearchInterpretOperator(int $rule_operator, string $rule) : string {
1384
		// Operator mapping is different for text, numeric, date, boolean, and day rules
1385
1386
		$textRules = [
1387
			'anywhere', 'title', 'song', 'album', 'artist', 'podcast', 'podcast_episode', 'album_artist', 'song_artist',
1388
			'favorite', 'favorite_album', 'favorite_artist', 'genre', 'song_genre', 'album_genre', 'artist_genre',
1389
			'playlist_name', 'type', 'file', 'mbid', 'mbid_album', 'mbid_artist', 'mbid_song'
1390
		];
1391
		// text but no support planned: 'composer', 'summary', 'placeformed', 'release_type', 'release_status', 'barcode',
1392
		// 'catalog_number', 'label', 'comment', 'lyrics', 'username', 'category'
1393
1394
		$numericRules = [
1395
			'track', 'year', 'original_year', 'myrating', 'rating', 'songrating', 'albumrating', 'artistrating',
1396
			'played_times', 'album_count', 'song_count', 'time'
1397
		];
1398
		// numeric but no support planned: 'yearformed', 'skipped_times', 'play_skip_ratio', 'image_height', 'image_width'
1399
1400
		$numericLimitRules = ['recent_played', 'recent_added', 'recent_updated'];
1401
1402
		$dateOrDayRules = ['added', 'updated', 'pubdate', 'last_play'];
1403
1404
		$booleanRules = [
1405
			'played', 'myplayed', 'myplayedalbum', 'myplayedartist', 'has_image', 'no_genre',
1406
			'my_flagged', 'my_flagged_album', 'my_flagged_artist'
1407
		];
1408
		// boolean but no support planned: 'smartplaylist', 'possible_duplicate', 'possible_duplicate_album'
1409
1410
		$booleanNumericRules = ['playlist'];
1411
		// boolean numeric but no support planned: 'license', 'state', 'catalog'
1412
1413
		if (\in_array($rule, $textRules)) {
1414
			switch ($rule_operator) {
1415
				case 0: return 'contain';		// contains
1416
				case 1: return 'notcontain';	// does not contain;
1417
				case 2: return 'start';			// starts with
1418
				case 3: return 'end';			// ends with;
1419
				case 4: return 'is';			// is
1420
				case 5: return 'isnot';			// is not
1421
				case 6: return 'sounds';		// sounds like
1422
				case 7: return 'notsounds';		// does not sound like
1423
				case 8: return 'regexp';		// matches regex
1424
				case 9: return 'notregexp';		// does not match regex
1425
				default: throw new AmpacheException("Search operator '$rule_operator' not supported for 'text' type rules", 400);
1426
			}
1427
		} elseif (\in_array($rule, $numericRules)) {
1428
			switch ($rule_operator) {
1429
				case 0: return '>=';
1430
				case 1: return '<=';
1431
				case 2: return '=';
1432
				case 3: return '!=';
1433
				case 4: return '>';
1434
				case 5: return '<';
1435
				default: throw new AmpacheException("Search operator '$rule_operator' not supported for 'numeric' type rules", 400);
1436
			}
1437
		} elseif (\in_array($rule, $numericLimitRules)) {
1438
			return 'limit';
1439
		} elseif (\in_array($rule, $dateOrDayRules)) {
1440
			switch ($rule_operator) {
1441
				case 0: return '<';
1442
				case 1: return '>';
1443
				default: throw new AmpacheException("Search operator '$rule_operator' not supported for 'date' or 'day' type rules", 400);
1444
			}
1445
		} elseif (\in_array($rule, $booleanRules)) {
1446
			switch ($rule_operator) {
1447
				case 0: return 'true';
1448
				case 1: return 'false';
1449
				default: throw new AmpacheException("Search operator '$rule_operator' not supported for 'boolean' type rules", 400);
1450
			}
1451
		} elseif (\in_array($rule, $booleanNumericRules)) {
1452
			switch ($rule_operator) {
1453
				case 0: return 'equal';
1454
				case 1: return 'ne';
1455
				default: throw new AmpacheException("Search operator '$rule_operator' not supported for 'boolean numeric' type rules", 400);
1456
			}
1457
		} else {
1458
			throw new AmpacheException("Search rule '$rule' not supported", 400);
1459
		}
1460
	}
1461
1462
	private static function advSearchConvertInput(string $input, string $rule) {
1463
		switch ($rule) {
1464
			case 'last_play':
1465
				// days diff to ISO date
1466
				$date = new \DateTime("$input days ago");
1467
				return $date->format(BaseMapper::SQL_DATE_FORMAT);
1468
			case 'time':
1469
				// minutes to seconds
1470
				return (string)(int)((float)$input * 60);
1471
			default:
1472
				return $input;
1473
		}
1474
	}
1475
1476
	private function getAppNameAndVersion() : string {
1477
		$vendor = 'owncloud/nextcloud'; // this should get overridden by the next 'include'
1478
		include \OC::$SERVERROOT . '/version.php';
1479
1480
		$appVersion = AppInfo::getVersion();
1481
1482
		return "$vendor {$this->appName} $appVersion";
1483
	}
1484
1485
	private function getCover(int $entityId, BusinessLayer $businessLayer) : Response {
1486
		$userId = $this->session->getUserId();
1487
		$userFolder = $this->librarySettings->getFolder($userId);
1488
1489
		try {
1490
			$entity = $businessLayer->find($entityId, $userId);
1491
			$coverData = $this->coverHelper->getCover($entity, $userId, $userFolder);
1492
			if ($coverData !== null) {
1493
				return new FileResponse($coverData);
1494
			}
1495
		} catch (BusinessLayerException $e) {
1496
			return new ErrorResponse(Http::STATUS_NOT_FOUND, 'entity not found');
1497
		}
1498
1499
		return new ErrorResponse(Http::STATUS_NOT_FOUND, 'entity has no cover');
1500
	}
1501
1502
	private function findEntities(
1503
			BusinessLayer $businessLayer, ?string $filter, bool $exact, ?int $limit=null, ?int $offset=null, ?string $add=null, ?string $update=null) : array {
1504
1505
		$userId = $this->session->getUserId();
1506
1507
		// It's not documented, but Ampache supports also specifying date range on `add` and `update` parameters
1508
		// by using '/' as separator. If there is no such separator, then the value is used as a lower limit.
1509
		$add = Util::explode('/', $add);
1510
		$update = Util::explode('/', $update);
1511
		$addMin = $add[0] ?? null;
1512
		$addMax = $add[1] ?? null;
1513
		$updateMin = $update[0] ?? null;
1514
		$updateMax = $update[1] ?? null;
1515
1516
		if ($filter) {
1517
			$matchMode = $exact ? MatchMode::Exact : MatchMode::Substring;
1518
			return $businessLayer->findAllByName($filter, $userId, $matchMode, $limit, $offset, $addMin, $addMax, $updateMin, $updateMax);
1519
		} else {
1520
			return $businessLayer->findAll($userId, SortBy::Name, $limit, $offset, $addMin, $addMax, $updateMin, $updateMax);
1521
		}
1522
	}
1523
1524
	/**
1525
	 * Common logic for the API methods `get_indexes` (deprecated in API6) and `list` (new in API6)
1526
	 */
1527
	private function listEntities(string $type, ?string $filter, ?string $add, ?string $update, int $limit, int $offset) : array {
1528
		if ($type === 'album_artist') {
1529
			if (!empty($add) || !empty($update)) {
1530
				throw new AmpacheException("Arguments 'add' and 'update' are not supported for the type 'album_artist'", 400);
1531
			}
1532
			$entities = $this->artistBusinessLayer->findAllHavingAlbums(
1533
				$this->session->getUserId(), SortBy::Name, $limit, $offset, $filter, MatchMode::Substring);
1534
			$type = 'artist';
0 ignored issues
show
Unused Code introduced by
The assignment to $type is dead and can be removed.
Loading history...
1535
		} else {
1536
			$businessLayer = $this->getBusinessLayer($type);
1537
			$entities = $this->findEntities($businessLayer, $filter, false, $limit, $offset, $add, $update);
1538
		}
1539
		return $entities;
1540
	}
1541
1542
	/**
1543
	 * @param PodcastChannel[] &$channels
1544
	 */
1545
	private function injectEpisodesToChannels(array &$channels) : void {
1546
		$userId = $this->session->getUserId();
1547
		$allChannelsIncluded = (\count($channels) === $this->podcastChannelBusinessLayer->count($userId));
1548
		$this->podcastService->injectEpisodes($channels, $userId, $allChannelsIncluded);
1549
	}
1550
1551
	private function createAmpacheActionUrl(string $action, int $id, ?string $type=null) : string {
1552
		$api = $this->jsonMode ? 'music.ampache.jsonApi' : 'music.ampache.xmlApi';
1553
		$auth = $this->session->getToken();
1554
		return $this->urlGenerator->linkToRouteAbsolute($api)
1555
				. "?action=$action&id=$id&auth=$auth"
1556
				. (!empty($type) ? "&type=$type" : '');
1557
	}
1558
1559
	private function createCoverUrl(Entity $entity) : string {
1560
		if ($entity instanceof Album) {
1561
			$type = 'album';
1562
		} elseif ($entity instanceof Artist) {
1563
			$type = 'artist';
1564
		} elseif ($entity instanceof Playlist) {
1565
			$type = 'playlist';
1566
		} else {
1567
			throw new AmpacheException('unexpeted entity type for cover image', 500);
1568
		}
1569
1570
		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

1570
		if ($type === 'playlist' || $entity->/** @scrutinizer ignore-call */ getCoverFileId()) {
Loading history...
1571
			$id = $entity->getId();
1572
			$token = $this->imageService->getToken($type, $id, $this->session->getAmpacheUserId());
1573
			return $this->urlGenerator->linkToRouteAbsolute('music.ampacheImage.image') . "?object_type=$type&object_id=$id&token=$token";
1574
		} else {
1575
			return '';
1576
		}
1577
	}
1578
1579
	private static function indexIsWithinOffsetAndLimit(int $index, ?int $offset, ?int $limit) : bool {
1580
		$offset = \intval($offset); // missing offset is interpreted as 0-offset
1581
		return ($limit === null) || ($index >= $offset && $index < $offset + $limit);
1582
	}
1583
1584
	private function prefixAndBaseName(?string $name) : array {
1585
		$parts = ['prefix' => null, 'basename' => $name];
1586
1587
		if ($name !== null) {
1588
			foreach ($this->namePrefixes as $prefix) {
1589
				if (Util::startsWith($name, $prefix . ' ', /*ignoreCase=*/true)) {
1590
					$parts['prefix'] = $prefix;
1591
					$parts['basename'] = \substr($name, \strlen($prefix) + 1);
1592
					break;
1593
				}
1594
			}
1595
		}
1596
1597
		return $parts;
1598
	}
1599
1600
	private function renderAlbumOrArtistRef(int $id, string $name) : array {
1601
		if ($this->apiMajorVersion() > 5) {
1602
			return [
1603
				'id' => (string)$id,
1604
				'name' => $name,
1605
			] + $this->prefixAndBaseName($name);
1606
		} else {
1607
			return [
1608
				'id' => (string)$id,
1609
				'text' => $name
1610
			];
1611
		}
1612
	}
1613
1614
	/**
1615
	 * @param Artist[] $artists
1616
	 */
1617
	private function renderArtists(array $artists) : array {
1618
		$userId = $this->session->getUserId();
1619
		$genreMap = Util::createIdLookupTable($this->genreBusinessLayer->findAll($userId));
1620
		$genreKey = $this->genreKey();
1621
		// In APIv3-4, the properties 'albums' and 'songs' were used for the album/song count in case the inclusion of the relevan
1622
		// child objects wasn't requested. APIv5+ has the dedoicated properties 'albumcount' and 'songcount' for this purpose.
1623
		$oldCountApi = ($this->apiMajorVersion() < 5);
1624
1625
		return [
1626
			'artist' => \array_map(function (Artist $artist) use ($userId, $genreMap, $genreKey, $oldCountApi) {
1627
				$name = $artist->getNameString($this->l10n);
1628
				$nameParts = $this->prefixAndBaseName($name);
1629
				$albumCount = $this->albumBusinessLayer->countByAlbumArtist($artist->getId());
1630
				$songCount = $this->trackBusinessLayer->countByArtist($artist->getId());
1631
				$albums = $artist->getAlbums();
1632
				$songs = $artist->getTracks();
1633
1634
				$apiArtist = [
1635
					'id' => (string)$artist->getId(),
1636
					'name' => $name,
1637
					'prefix' => $nameParts['prefix'],
1638
					'basename' => $nameParts['basename'],
1639
					'albums' => ($albums !== null) ? $this->renderAlbums($albums) : ($oldCountApi ? $albumCount : null),
1640
					'albumcount' => $albumCount,
1641
					'songs' => ($songs !== null) ? $this->renderSongs($songs) : ($oldCountApi ? $songCount : null),
1642
					'songcount' => $songCount,
1643
					'time' => $this->trackBusinessLayer->totalDurationByArtist($artist->getId()),
1644
					'art' => $this->createCoverUrl($artist),
1645
					'rating' => $artist->getRating() ?? 0,
1646
					'preciserating' => $artist->getRating() ?? 0,
1647
					'flag' => !empty($artist->getStarred()),
1648
					$genreKey => \array_map(function ($genreId) use ($genreMap) {
1649
						return [
1650
							'id' => (string)$genreId,
1651
							'text' => $genreMap[$genreId]->getNameString($this->l10n),
1652
							'count' => 1
1653
						];
1654
					}, $this->trackBusinessLayer->getGenresByArtistId($artist->getId(), $userId))
1655
				];
1656
1657
				if ($this->jsonMode) {
1658
					// Remove an unnecessary level on the JSON API
1659
					if ($albums !== null) {
1660
						$apiArtist['albums'] = $apiArtist['albums']['album'];
1661
					}
1662
					if ($songs !== null) {
1663
						$apiArtist['songs'] = $apiArtist['songs']['song'];
1664
					}
1665
				}
1666
1667
				return $apiArtist;
1668
			}, $artists)
1669
		];
1670
	}
1671
1672
	/**
1673
	 * @param Album[] $albums
1674
	 */
1675
	private function renderAlbums(array $albums) : array {
1676
		$genreKey = $this->genreKey();
1677
		$apiMajor = $this->apiMajorVersion();
1678
		// In APIv6 JSON format, there is a new property `artists` with an array value
1679
		$includeArtists = ($this->jsonMode && $apiMajor > 5);
1680
		// In APIv3-4, the property 'tracks' was used for the song count in case the inclusion of songs wasn't requested.
1681
		// APIv5+ has the property 'songcount' for this and 'tracks' may only contain objects.
1682
		$tracksMayDenoteCount = ($apiMajor < 5);
1683
1684
		return [
1685
			'album' => \array_map(function (Album $album) use ($genreKey, $includeArtists, $tracksMayDenoteCount) {
1686
				$name = $album->getNameString($this->l10n);
1687
				$nameParts = $this->prefixAndBaseName($name);
1688
				$songCount = $this->trackBusinessLayer->countByAlbum($album->getId());
1689
				$songs = $album->getTracks();
1690
1691
				$apiAlbum = [
1692
					'id' => (string)$album->getId(),
1693
					'name' => $name,
1694
					'prefix' => $nameParts['prefix'],
1695
					'basename' => $nameParts['basename'],
1696
					'artist' => $this->renderAlbumOrArtistRef(
1697
						$album->getAlbumArtistId(),
1698
						$album->getAlbumArtistNameString($this->l10n)
1699
					),
1700
					'tracks' => ($songs !== null) ? $this->renderSongs($songs, false) : ($tracksMayDenoteCount ? $songCount : null),
1701
					'songcount' => $songCount,
1702
					'diskcount' => $album->getNumberOfDisks(),
1703
					'time' => $this->trackBusinessLayer->totalDurationOfAlbum($album->getId()),
1704
					'rating' => $album->getRating() ?? 0,
1705
					'preciserating' => $album->getRating() ?? 0,
1706
					'year' => $album->yearToAPI(),
1707
					'art' => $this->createCoverUrl($album),
1708
					'flag' => !empty($album->getStarred()),
1709
					$genreKey => \array_map(function ($genre) {
1710
						return [
1711
							'id' => (string)$genre->getId(),
1712
							'text' => $genre->getNameString($this->l10n),
1713
							'count' => 1
1714
						];
1715
					}, $album->getGenres() ?? [])
1716
				];
1717
				if ($includeArtists) {
1718
					$apiAlbum['artists'] = [$apiAlbum['artist']];
1719
				}
1720
				if ($this->jsonMode && $songs !== null) {
1721
					// Remove an unnecessary level on the JSON API
1722
					$apiAlbum['tracks'] = $apiAlbum['tracks']['song'];
1723
				}
1724
1725
				return $apiAlbum;
1726
			}, $albums)
1727
		];
1728
	}
1729
1730
	/**
1731
	 * @param Track[] $tracks
1732
	 */
1733
	private function renderSongs(array $tracks, bool $injectAlbums=true) : array {
1734
		if ($injectAlbums) {
1735
			$userId = $this->session->getUserId();
1736
			$this->albumBusinessLayer->injectAlbumsToTracks($tracks, $userId);
1737
		}
1738
1739
		$createPlayUrl = function(Track $track) : string {
1740
			return $this->createAmpacheActionUrl('download', $track->getId());
1741
		};
1742
		$createImageUrl = function(Track $track) : string {
1743
			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

1743
			return $this->createCoverUrl(/** @scrutinizer ignore-type */ $track->getAlbum());
Loading history...
1744
		};
1745
		$renderRef = function(int $id, string $name) : array {
1746
			return $this->renderAlbumOrArtistRef($id, $name);
1747
		};
1748
		$genreKey = $this->genreKey();
1749
		// In APIv6 JSON format, there is a new property `artists` with an array value
1750
		$includeArtists = ($this->jsonMode && $this->apiMajorVersion() > 5);
1751
1752
		return [
1753
			'song' => Util::arrayMapMethod($tracks, 'toAmpacheApi', 
1754
				[$this->l10n, $createPlayUrl, $createImageUrl, $renderRef, $genreKey, $includeArtists])
1755
		];
1756
	}
1757
1758
	/**
1759
	 * @param Playlist[] $playlists
1760
	 */
1761
	private function renderPlaylists(array $playlists) : array {
1762
		$createImageUrl = function(Playlist $playlist) : string {
1763
			if ($playlist->getId() === self::ALL_TRACKS_PLAYLIST_ID) {
1764
				return '';
1765
			} else {
1766
				return $this->createCoverUrl($playlist);
1767
			}
1768
		};
1769
1770
		return [
1771
			'playlist' => Util::arrayMapMethod($playlists, 'toAmpacheApi', [$createImageUrl])
1772
		];
1773
	}
1774
1775
	/**
1776
	 * @param PodcastChannel[] $channels
1777
	 */
1778
	private function renderPodcastChannels(array $channels) : array {
1779
		return [
1780
			'podcast' => Util::arrayMapMethod($channels, 'toAmpacheApi')
1781
		];
1782
	}
1783
1784
	/**
1785
	 * @param PodcastEpisode[] $episodes
1786
	 */
1787
	private function renderPodcastEpisodes(array $episodes) : array {
1788
		return [
1789
			'podcast_episode' => Util::arrayMapMethod($episodes, 'toAmpacheApi')
1790
		];
1791
	}
1792
1793
	/**
1794
	 * @param RadioStation[] $stations
1795
	 */
1796
	private function renderLiveStreams(array $stations) : array {
1797
		return [
1798
			'live_stream' => Util::arrayMapMethod($stations, 'toAmpacheApi')
1799
		];
1800
	}
1801
1802
	/**
1803
	 * @param Genre[] $genres
1804
	 */
1805
	private function renderTags(array $genres) : array {
1806
		return [
1807
			'tag' => Util::arrayMapMethod($genres, 'toAmpacheApi', [$this->l10n])
1808
		];
1809
	}
1810
1811
	/**
1812
	 * @param Genre[] $genres
1813
	 */
1814
	private function renderGenres(array $genres) : array {
1815
		return [
1816
			'genre' => Util::arrayMapMethod($genres, 'toAmpacheApi', [$this->l10n])
1817
		];
1818
	}
1819
1820
	/**
1821
	 * @param Bookmark[] $bookmarks
1822
	 */
1823
	private function renderBookmarks(array $bookmarks) : array {
1824
		return [
1825
			'bookmark' => Util::arrayMapMethod($bookmarks, 'toAmpacheApi')
1826
		];
1827
	}
1828
1829
	/**
1830
	 * @param Track[] $tracks
1831
	 */
1832
	private function renderSongsIndex(array $tracks) : array {
1833
		return [
1834
			'song' => \array_map(function ($track) {
1835
				return [
1836
					'id' => (string)$track->getId(),
1837
					'title' => $track->getTitle(),
1838
					'name' => $track->getTitle(),
1839
					'artist' => $this->renderAlbumOrArtistRef($track->getArtistId(), $track->getArtistNameString($this->l10n)),
1840
					'album' => $this->renderAlbumOrArtistRef($track->getAlbumId(), $track->getAlbumNameString($this->l10n))
1841
				];
1842
			}, $tracks)
1843
		];
1844
	}
1845
1846
	/**
1847
	 * @param Album[] $albums
1848
	 */
1849
	private function renderAlbumsIndex(array $albums) : array {
1850
		return [
1851
			'album' => \array_map(function ($album) {
1852
				$name = $album->getNameString($this->l10n);
1853
				$nameParts = $this->prefixAndBaseName($name);
1854
1855
				return [
1856
					'id' => (string)$album->getId(),
1857
					'name' => $name,
1858
					'prefix' => $nameParts['prefix'],
1859
					'basename' => $nameParts['basename'],
1860
					'artist' => $this->renderAlbumOrArtistRef($album->getAlbumArtistId(), $album->getAlbumArtistNameString($this->l10n))
1861
				];
1862
			}, $albums)
1863
		];
1864
	}
1865
1866
	/**
1867
	 * @param Artist[] $artists
1868
	 */
1869
	private function renderArtistsIndex(array $artists) : array {
1870
		return [
1871
			'artist' => \array_map(function ($artist) {
1872
				$userId = $this->session->getUserId();
1873
				$albums = $this->albumBusinessLayer->findAllByArtist($artist->getId(), $userId);
1874
				$name = $artist->getNameString($this->l10n);
1875
				$nameParts = $this->prefixAndBaseName($name);
1876
1877
				return [
1878
					'id' => (string)$artist->getId(),
1879
					'name' => $name,
1880
					'prefix' => $nameParts['prefix'],
1881
					'basename' => $nameParts['basename'],
1882
					'album' => \array_map(function ($album) {
1883
						return $this->renderAlbumOrArtistRef($album->getId(), $album->getNameString($this->l10n));
1884
					}, $albums)
1885
				];
1886
			}, $artists)
1887
		];
1888
	}
1889
1890
	/**
1891
	 * @param Playlist[] $playlists
1892
	 */
1893
	private function renderPlaylistsIndex(array $playlists) : array {
1894
		return [
1895
			'playlist' => \array_map(function ($playlist) {
1896
				return [
1897
					'id' => (string)$playlist->getId(),
1898
					'name' => $playlist->getName(),
1899
					'playlisttrack' => $playlist->getTrackIdsAsArray()
1900
				];
1901
			}, $playlists)
1902
		];
1903
	}
1904
1905
	/**
1906
	 * @param PodcastChannel[] $channels
1907
	 */
1908
	private function renderPodcastChannelsIndex(array $channels) : array {
1909
		// The v4 API spec does not give any examples of this, and the v5 example is almost identical to the v4 "normal" result
1910
		return $this->renderPodcastChannels($channels);
1911
	}
1912
1913
	/**
1914
	 * @param PodcastEpisode[] $episodes
1915
	 */
1916
	private function renderPodcastEpisodesIndex(array $episodes) : array {
1917
		// The v4 API spec does not give any examples of this, and the v5 example is almost identical to the v4 "normal" result
1918
		return $this->renderPodcastEpisodes($episodes);
1919
	}
1920
1921
	/**
1922
	 * @param RadioStation[] $stations
1923
	 */
1924
	private function renderLiveStreamsIndex(array $stations) : array {
1925
		// The API spec gives no examples of this, but testing with Ampache demo server revealed that the format is indentical to the "full" format
1926
		return $this->renderLiveStreams($stations);
1927
	}
1928
1929
	/**
1930
	 * @param Entity[] $entities
1931
	 */
1932
	private function renderEntitiesList($entities) : array {
1933
		return [
1934
			'list' => \array_map(function ($entity) {
1935
				$name = $entity->getNameString($this->l10n);
1936
				return [
1937
					'id' => (string)$entity->getId(),
1938
					'name' => $name
1939
				] + $this->prefixAndBaseName($name);
1940
			}, $entities)
1941
		];
1942
	}
1943
1944
	/**
1945
	 * @param Entity[] $entities
1946
	 */
1947
	private function renderEntityIds(array $entities) : array {
1948
		return ['id' => Util::extractIds($entities)];
1949
	}
1950
1951
	/**
1952
	 * Array is considered to be "indexed" if its first element has numerical key.
1953
	 * Empty array is considered to be "indexed".
1954
	 */
1955
	private static function arrayIsIndexed(array $array) : bool {
1956
		\reset($array);
1957
		return empty($array) || \is_int(\key($array));
1958
	}
1959
1960
	/**
1961
	 * The JSON API has some asymmetries with the XML API. This function makes the needed
1962
	 * translations for the result content before it is converted into JSON.
1963
	 */
1964
	private function prepareResultForJsonApi(array $content) : array {
1965
		$apiVer = $this->apiMajorVersion();
1966
1967
		// Special handling is needed for responses returning an array of library entities,
1968
		// depending on the API version. In these cases, the outermost array is of associative
1969
		// type with a single value which is a non-associative array.
1970
		if (\count($content) === 1 && !self::arrayIsIndexed($content)
1971
				&& \is_array(\current($content)) && self::arrayIsIndexed(\current($content))) {
1972
			// In API versions < 5, the root node is an anonymous array. Unwrap the outermost array.
1973
			if ($apiVer < 5) {
1974
				$content = \array_pop($content);
1975
			}
1976
			// In later versions, the root object has a named array for plural actions (like "songs", "artists").
1977
			// For singular actions (like "song", "artist"), the root object contains directly the entity properties.
1978
			else {
1979
				$action = $this->request->getParam('action');
1980
				$plural = (\substr($action, -1) === 's' || \in_array($action, ['get_similar', 'advanced_search', 'list']));
1981
1982
				// In APIv5, the action "album" is an excption, it is formatted as if it was a plural action.
1983
				// This outlier has been fixed in APIv6.
1984
				$api5albumOddity = ($apiVer === 5 && $action === 'album');
1985
1986
				// The actions "user_preference" and "system_preference" are another kind of outliers in APIv5,
1987
				// their reponses are anonymou 1-item arrays. This got fixed in the APIv6.0.1
1988
				$api5preferenceOddity = ($apiVer === 5 && Util::endsWith($action, 'preference'));
1989
1990
				if ($api5preferenceOddity) {
1991
					$content = \array_pop($content);
1992
				} elseif (!($plural  || $api5albumOddity)) {
1993
					$content = \array_pop($content);
1994
					$content = \array_pop($content);
1995
				}
1996
			}
1997
		}
1998
1999
		// In API versions < 6, all boolean valued properties should be converted to 0/1.
2000
		if ($apiVer < 6) {
2001
			Util::intCastArrayValues($content, 'is_bool');
2002
		}
2003
2004
		// The key 'text' has a special meaning on XML responses, as it makes the corresponding value
2005
		// to be treated as text content of the parent element. In the JSON API, these are mostly
2006
		// substituted with property 'name', but error responses use the property 'message', instead.
2007
		if (\array_key_exists('error', $content)) {
2008
			$content = Util::convertArrayKeys($content, ['text' => 'message']);
2009
		} else {
2010
			$content = Util::convertArrayKeys($content, ['text' => 'name']);
2011
		}
2012
		return $content;
2013
	}
2014
2015
	/**
2016
	 * The XML API has some asymmetries with the JSON API. This function makes the needed
2017
	 * translations for the result content before it is converted into XML.
2018
	 */
2019
	private function prepareResultForXmlApi(array $content) : array {
2020
		\reset($content);
2021
		$firstKey = \key($content);
2022
2023
		// all 'entity list' kind of responses shall have the (deprecated) total_count element
2024
		if ($firstKey == 'song' || $firstKey == 'album' || $firstKey == 'artist' || $firstKey == 'playlist'
2025
				|| $firstKey == 'tag' || $firstKey == 'genre' || $firstKey == 'podcast' || $firstKey == 'podcast_episode'
2026
				|| $firstKey == 'live_stream') {
2027
			$content = ['total_count' => \count($content[$firstKey])] + $content;
2028
		}
2029
2030
		// for some bizarre reason, the 'id' arrays have 'index' attributes in the XML format
2031
		if ($firstKey == 'id') {
2032
			$content['id'] = \array_map(function ($id, $index) {
2033
				return ['index' => $index, 'text' => $id];
2034
			}, $content['id'], \array_keys($content['id']));
2035
		}
2036
2037
		return ['root' => $content];
2038
	}
2039
2040
	private function genreKey() : string {
2041
		return ($this->apiMajorVersion() > 4) ? 'genre' : 'tag';
2042
	}
2043
2044
	private function apiMajorVersion() : int {
2045
		// During the handshake, we don't yet have a session but the requeted version may be in the request args
2046
		$verString = ($this->session !== null) 
2047
			? $this->session->getApiVersion()
2048
			: $this->request->getParam('version');
2049
		
2050
		if (\is_string($verString) && \strlen($verString)) {
2051
			$ver = (int)$verString[0];
2052
		} else {
2053
			// Default version is 6 unless otherwise defined in config.php
2054
			$ver = (int)$this->config->getSystemValue('music.ampache_api_default_ver', 6);
2055
		}
2056
2057
		// For now, we have three supported major versions. Major version 3 can be sufficiently supported
2058
		// with our "version 4" implementation.
2059
		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...
2060
	}
2061
2062
	private function apiVersionString() : string {
2063
		switch ($this->apiMajorVersion()) {
2064
			case 4:		return self::API4_VERSION;
2065
			case 5:		return self::API5_VERSION;
2066
			case 6:		return self::API6_VERSION;
2067
			default:	throw new AmpacheException('Unexpected api major version', 500);
2068
		}
2069
	}
2070
2071
	private function mapApiV4ErrorToV5(int $code) : int {
2072
		switch ($code) {
2073
			case 400:	return 4710;	// bad request
2074
			case 401:	return 4701;	// invalid handshake
2075
			case 403:	return 4703;	// access denied
2076
			case 404:	return 4704;	// not found
2077
			case 405:	return 4705;	// missing
2078
			case 412:	return 4742;	// failed access check
2079
			case 501:	return 4700;	// access control not enabled
2080
			default:	return 5000;	// unexcpected (not part of the API spec)
2081
		}
2082
	}
2083
}
2084
2085
/**
2086
 * Adapter class which acts like the Playlist class for the purpose of
2087
 * AmpacheController::renderPlaylists but contains all the track of the user.
2088
 */
2089
class AmpacheController_AllTracksPlaylist extends Playlist {
2090
	private $trackBusinessLayer;
2091
	private $l10n;
2092
2093
	public function __construct(string $userId, TrackBusinessLayer $trackBusinessLayer, IL10N $l10n) {
2094
		$this->userId = $userId;
2095
		$this->trackBusinessLayer = $trackBusinessLayer;
2096
		$this->l10n = $l10n;
2097
	}
2098
2099
	public function getId() : int {
2100
		return AmpacheController::ALL_TRACKS_PLAYLIST_ID;
2101
	}
2102
2103
	public function getName() : string {
2104
		return $this->l10n->t('All tracks');
2105
	}
2106
2107
	public function getTrackCount() : int {
2108
		return $this->trackBusinessLayer->count($this->userId);
2109
	}
2110
}
2111