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

AmpacheController::prepareResultForJsonApi()   C

Complexity

Conditions 13
Paths 56

Size

Total Lines 43
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 4
Bugs 0 Features 0
Metric Value
cc 13
eloc 19
c 4
b 0
f 0
nc 56
nop 1
dl 0
loc 43
rs 6.6166

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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

1101
				return $this->download(/** @scrutinizer ignore-type */ Random::pickItem($songIds));
Loading history...
1102
			}
1103
		} else {
1104
			throw new AmpacheException("Unsupported type '$type'", 400);
1105
		}
1106
	}
1107
1108
	/**
1109
	 * @AmpacheAPI
1110
	 */
1111
	protected function stream(int $id, ?int $offset, string $type='song') : Response {
1112
		// request params `bitrate`, `format`, and `length` are ignored
1113
1114
		// This is just a dummy implementation. We don't support transcoding or streaming
1115
		// from a time offset.
1116
		// All the other unsupported arguments are just ignored, but a request with an offset
1117
		// is responded with an error. This is becuase the client would probably work in an
1118
		// unexpected way if it thinks it's streaming from offset but actually it is streaming
1119
		// from the beginning of the file. Returning an error gives the client a chance to fallback
1120
		// to other methods of seeking.
1121
		if ($offset !== null) {
1122
			throw new AmpacheException('Streaming with time offset is not supported', 400);
1123
		}
1124
1125
		return $this->download($id, $type);
1126
	}
1127
1128
	/**
1129
	 * @AmpacheAPI
1130
	 */
1131
	protected function get_art(string $type, int $id) : Response {
1132
		if (!\in_array($type, ['song', 'album', 'artist', 'podcast', 'playlist'])) {
1133
			throw new AmpacheException("Unsupported type $type", 400);
1134
		}
1135
1136
		if ($type === 'song') {
1137
			// map song to its parent album
1138
			$id = $this->trackBusinessLayer->find($id, $this->session->getUserId())->getAlbumId();
1139
			$type = 'album';
1140
		}
1141
1142
		return $this->getCover($id, $this->getBusinessLayer($type));
1143
	}
1144
1145
	/********************
1146
	 * Helper functions *
1147
	 ********************/
1148
1149
	private function getBusinessLayer(string $type) : BusinessLayer {
1150
		switch ($type) {
1151
			case 'song':			return $this->trackBusinessLayer;
1152
			case 'album':			return $this->albumBusinessLayer;
1153
			case 'artist':			return $this->artistBusinessLayer;
1154
			case 'playlist':		return $this->playlistBusinessLayer;
1155
			case 'podcast':			return $this->podcastChannelBusinessLayer;
1156
			case 'podcast_episode':	return $this->podcastEpisodeBusinessLayer;
1157
			case 'live_stream':		return $this->radioStationBusinessLayer;
1158
			case 'tag':				return $this->genreBusinessLayer;
1159
			case 'genre':			return $this->genreBusinessLayer;
1160
			case 'bookmark':		return $this->bookmarkBusinessLayer;
1161
			default:				throw new AmpacheException("Unsupported type $type", 400);
1162
		}
1163
	}
1164
1165
	private function renderEntities(array $entities, string $type) : array {
1166
		switch ($type) {
1167
			case 'song':			return $this->renderSongs($entities);
1168
			case 'album':			return $this->renderAlbums($entities);
1169
			case 'artist':			return $this->renderArtists($entities);
1170
			case 'playlist':		return $this->renderPlaylists($entities);
1171
			case 'podcast':			return $this->renderPodcastChannels($entities);
1172
			case 'podcast_episode':	return $this->renderPodcastEpisodes($entities);
1173
			case 'live_stream':		return $this->renderLiveStreams($entities);
1174
			case 'tag':				return $this->renderTags($entities);
1175
			case 'genre':			return $this->renderGenres($entities);
1176
			case 'bookmark':		return $this->renderBookmarks($entities);
1177
			default:				throw new AmpacheException("Unsupported type $type", 400);
1178
		}
1179
	}
1180
1181
	private function renderEntitiesIndex($entities, $type) : array {
1182
		switch ($type) {
1183
			case 'song':			return $this->renderSongsIndex($entities);
1184
			case 'album':			return $this->renderAlbumsIndex($entities);
1185
			case 'artist':			return $this->renderArtistsIndex($entities);
1186
			case 'playlist':		return $this->renderPlaylistsIndex($entities);
1187
			case 'podcast':			return $this->renderPodcastChannelsIndex($entities);
1188
			case 'podcast_episode':	return $this->renderPodcastEpisodesIndex($entities);
1189
			case 'live_stream':		return $this->renderLiveStreamsIndex($entities);
1190
			case 'tag':				return $this->renderTags($entities); // not part of the API spec
1191
			case 'genre':			return $this->renderGenres($entities); // not part of the API spec
1192
			case 'bookmark':		return $this->renderBookmarks($entities); // not part of the API spec
1193
			default:				throw new AmpacheException("Unsupported type $type", 400);
1194
		}
1195
	}
1196
1197
	private static function mapBookmarkType(string $ampacheType) : int {
1198
		switch ($ampacheType) {
1199
			case 'song':			return Bookmark::TYPE_TRACK;
1200
			case 'podcast_episode':	return Bookmark::TYPE_PODCAST_EPISODE;
1201
			default:				throw new AmpacheException("Unsupported type $ampacheType", 400);
1202
		}
1203
	}
1204
1205
	private function getAppNameAndVersion() : string {
1206
		$vendor = 'owncloud/nextcloud'; // this should get overridden by the next 'include'
1207
		include \OC::$SERVERROOT . '/version.php';
1208
1209
		$appVersion = AppInfo::getVersion();
1210
1211
		return "$vendor {$this->appName} $appVersion";
1212
	}
1213
1214
	private function getCover(int $entityId, BusinessLayer $businessLayer) : Response {
1215
		$userId = $this->session->getUserId();
1216
		$userFolder = $this->librarySettings->getFolder($userId);
1217
1218
		try {
1219
			$entity = $businessLayer->find($entityId, $userId);
1220
			$coverData = $this->coverHelper->getCover($entity, $userId, $userFolder);
1221
			if ($coverData !== null) {
1222
				return new FileResponse($coverData);
1223
			}
1224
		} catch (BusinessLayerException $e) {
1225
			return new ErrorResponse(Http::STATUS_NOT_FOUND, 'entity not found');
1226
		}
1227
1228
		return new ErrorResponse(Http::STATUS_NOT_FOUND, 'entity has no cover');
1229
	}
1230
1231
	private function findEntities(
1232
			BusinessLayer $businessLayer, ?string $filter, bool $exact, ?int $limit=null, ?int $offset=null, ?string $add=null, ?string $update=null) : array {
1233
1234
		$userId = $this->session->getUserId();
1235
1236
		// It's not documented, but Ampache supports also specifying date range on `add` and `update` parameters
1237
		// by using '/' as separator. If there is no such separator, then the value is used as a lower limit.
1238
		$add = Util::explode('/', $add);
1239
		$update = Util::explode('/', $update);
1240
		$addMin = $add[0] ?? null;
1241
		$addMax = $add[1] ?? null;
1242
		$updateMin = $update[0] ?? null;
1243
		$updateMax = $update[1] ?? null;
1244
1245
		if ($filter) {
1246
			$matchMode = $exact ? MatchMode::Exact : MatchMode::Substring;
1247
			return $businessLayer->findAllByName($filter, $userId, $matchMode, $limit, $offset, $addMin, $addMax, $updateMin, $updateMax);
1248
		} else {
1249
			return $businessLayer->findAll($userId, SortBy::Name, $limit, $offset, $addMin, $addMax, $updateMin, $updateMax);
1250
		}
1251
	}
1252
1253
	private function createAmpacheActionUrl(string $action, int $id, ?string $type=null) : string {
1254
		$api = $this->jsonMode ? 'music.ampache.jsonApi' : 'music.ampache.xmlApi';
1255
		$auth = $this->session->getToken();
1256
		return $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute($api))
1257
				. "?action=$action&id=$id&auth=$auth"
1258
				. (!empty($type) ? "&type=$type" : '');
1259
	}
1260
1261
	private function createCoverUrl(Entity $entity) : string {
1262
		if ($entity instanceof Album) {
1263
			$type = 'album';
1264
		} elseif ($entity instanceof Artist) {
1265
			$type = 'artist';
1266
		} elseif ($entity instanceof Playlist) {
1267
			$type = 'playlist';
1268
		} else {
1269
			throw new AmpacheException('unexpeted entity type for cover image', 500);
1270
		}
1271
1272
		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

1272
		if ($type === 'playlist' || $entity->/** @scrutinizer ignore-call */ getCoverFileId()) {
Loading history...
1273
			$id = $entity->getId();
1274
			$token = $this->imageService->getToken($type, $id, $this->session->getAmpacheUserId());
1275
			return $this->urlGenerator->getAbsoluteURL(
1276
				$this->urlGenerator->linkToRoute('music.ampacheImage.image') . "?object_type=$type&object_id=$id&token=$token"
1277
			);
1278
		} else {
1279
			return '';
1280
		}
1281
	}
1282
1283
	private static function indexIsWithinOffsetAndLimit(int $index, ?int $offset, ?int $limit) : bool {
1284
		$offset = \intval($offset); // missing offset is interpreted as 0-offset
1285
		return ($limit === null) || ($index >= $offset && $index < $offset + $limit);
1286
	}
1287
1288
	private function prefixAndBaseName(?string $name) : array {
1289
		$parts = ['prefix' => null, 'basename' => $name];
1290
1291
		if ($name !== null) {
1292
			foreach ($this->namePrefixes as $prefix) {
1293
				if (Util::startsWith($name, $prefix . ' ', /*ignoreCase=*/true)) {
1294
					$parts['prefix'] = $prefix;
1295
					$parts['basename'] = \substr($name, \strlen($prefix) + 1);
1296
					break;
1297
				}
1298
			}
1299
		}
1300
1301
		return $parts;
1302
	}
1303
1304
	private function renderAlbumOrArtistRef(int $id, string $name) : array {
1305
		if ($this->apiMajorVersion() > 5) {
1306
			return [
1307
				'id' => (string)$id,
1308
				'name' => $name,
1309
			] + $this->prefixAndBaseName($name);
1310
		} else {
1311
			return [
1312
				'id' => (string)$id,
1313
				'value' => $name
1314
			];
1315
		}
1316
	}
1317
1318
	/**
1319
	 * @param Artist[] $artists
1320
	 */
1321
	private function renderArtists(array $artists) : array {
1322
		$userId = $this->session->getUserId();
1323
		$genreMap = Util::createIdLookupTable($this->genreBusinessLayer->findAll($userId));
1324
		$genreKey = $this->genreKey();
1325
1326
		return [
1327
			'artist' => \array_map(function (Artist $artist) use ($userId, $genreMap, $genreKey) {
1328
				$albumCount = $this->albumBusinessLayer->countByArtist($artist->getId());
1329
				$songCount = $this->trackBusinessLayer->countByArtist($artist->getId());
1330
				$name = $artist->getNameString($this->l10n);
1331
				$nameParts = $this->prefixAndBaseName($name);
1332
				return [
1333
					'id' => (string)$artist->getId(),
1334
					'name' => $name,
1335
					'prefix' => $nameParts['prefix'],
1336
					'basename' => $nameParts['basename'],
1337
					'albums' => $albumCount, // TODO: this should contain objects if requested; in API5+, this never contains the count
1338
					'albumcount' => $albumCount,
1339
					'songs' => $songCount, // TODO: this should contain objects if requested; in API5+, this never contains the count
1340
					'songcount' => $songCount,
1341
					'time' => $this->trackBusinessLayer->totalDurationByArtist($artist->getId()),
1342
					'art' => $this->createCoverUrl($artist),
1343
					'rating' => 0,
1344
					'preciserating' => 0,
1345
					'flag' => !empty($artist->getStarred()),
1346
					$genreKey => \array_map(function ($genreId) use ($genreMap) {
1347
						return [
1348
							'id' => (string)$genreId,
1349
							'value' => $genreMap[$genreId]->getNameString($this->l10n),
1350
							'count' => 1
1351
						];
1352
					}, $this->trackBusinessLayer->getGenresByArtistId($artist->getId(), $userId))
1353
				];
1354
			}, $artists)
1355
		];
1356
	}
1357
1358
	/**
1359
	 * @param Album[] $albums
1360
	 */
1361
	private function renderAlbums(array $albums) : array {
1362
		$genreKey = $this->genreKey();
1363
		// In APIv6 JSON format, there is a new property `artists` with an array value
1364
		$includeArtists = ($this->jsonMode && $this->apiMajorVersion() > 5);
1365
1366
		return [
1367
			'album' => \array_map(function (Album $album) use ($genreKey, $includeArtists) {
1368
				$songCount = $this->trackBusinessLayer->countByAlbum($album->getId());
1369
				$name = $album->getNameString($this->l10n);
1370
				$nameParts = $this->prefixAndBaseName($name);
1371
				$apiAlbum = [
1372
					'id' => (string)$album->getId(),
1373
					'name' => $name,
1374
					'prefix' => $nameParts['prefix'],
1375
					'basename' => $nameParts['basename'],
1376
					'artist' => $this->renderAlbumOrArtistRef(
1377
						$album->getAlbumArtistId(),
1378
						$album->getAlbumArtistNameString($this->l10n)
1379
					),
1380
					'tracks' => $songCount, // TODO: this should contain objects if requested; in API5+, this never contains the count
1381
					'songcount' => $songCount,
1382
					'time' => $this->trackBusinessLayer->totalDurationOfAlbum($album->getId()),
1383
					'rating' => 0,
1384
					'year' => $album->yearToAPI(),
1385
					'art' => $this->createCoverUrl($album),
1386
					'preciserating' => 0,
1387
					'flag' => !empty($album->getStarred()),
1388
					$genreKey => \array_map(function ($genre) {
1389
						return [
1390
							'id' => (string)$genre->getId(),
1391
							'value' => $genre->getNameString($this->l10n),
1392
							'count' => 1
1393
						];
1394
					}, $album->getGenres() ?? [])
1395
				];
1396
				if ($includeArtists) {
1397
					$apiAlbum['artists'] = [$apiAlbum['artist']];
1398
				}
1399
1400
				return $apiAlbum;
1401
			}, $albums)
1402
		];
1403
	}
1404
1405
	/**
1406
	 * @param Track[] $tracks
1407
	 */
1408
	private function renderSongs(array $tracks) : array {
1409
		$userId = $this->session->getUserId();
1410
		$this->albumBusinessLayer->injectAlbumsToTracks($tracks, $userId);
1411
1412
		$createPlayUrl = function(Track $track) : string {
1413
			return $this->createAmpacheActionUrl('download', $track->getId());
1414
		};
1415
		$createImageUrl = function(Track $track) : string {
1416
			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

1416
			return $this->createCoverUrl(/** @scrutinizer ignore-type */ $track->getAlbum());
Loading history...
1417
		};
1418
		$renderRef = function(int $id, string $name) : array {
1419
			return $this->renderAlbumOrArtistRef($id, $name);
1420
		};
1421
		$genreKey = $this->genreKey();
1422
		// In APIv6 JSON format, there is a new property `artists` with an array value
1423
		$includeArtists = ($this->jsonMode && $this->apiMajorVersion() > 5);
1424
1425
		return [
1426
			'song' => Util::arrayMapMethod($tracks, 'toAmpacheApi', 
1427
				[$this->l10n, $createPlayUrl, $createImageUrl, $renderRef, $genreKey, $includeArtists])
1428
		];
1429
	}
1430
1431
	/**
1432
	 * @param Playlist[] $playlists
1433
	 */
1434
	private function renderPlaylists(array $playlists) : array {
1435
		$createImageUrl = function(Playlist $playlist) : string {
1436
			if ($playlist->getId() === self::ALL_TRACKS_PLAYLIST_ID) {
1437
				return '';
1438
			} else {
1439
				return $this->createCoverUrl($playlist);
1440
			}
1441
		};
1442
1443
		return [
1444
			'playlist' => Util::arrayMapMethod($playlists, 'toAmpacheApi', [$createImageUrl])
1445
		];
1446
	}
1447
1448
	/**
1449
	 * @param PodcastChannel[] $channels
1450
	 */
1451
	private function renderPodcastChannels(array $channels) : array {
1452
		return [
1453
			'podcast' => Util::arrayMapMethod($channels, 'toAmpacheApi')
1454
		];
1455
	}
1456
1457
	/**
1458
	 * @param PodcastEpisode[] $episodes
1459
	 */
1460
	private function renderPodcastEpisodes(array $episodes) : array {
1461
		return [
1462
			'podcast_episode' => Util::arrayMapMethod($episodes, 'toAmpacheApi')
1463
		];
1464
	}
1465
1466
	/**
1467
	 * @param RadioStation[] $stations
1468
	 */
1469
	private function renderLiveStreams(array $stations) : array {
1470
		return [
1471
			'live_stream' => Util::arrayMapMethod($stations, 'toAmpacheApi')
1472
		];
1473
	}
1474
1475
	/**
1476
	 * @param Genre[] $genres
1477
	 */
1478
	private function renderTags(array $genres) : array {
1479
		return [
1480
			'tag' => Util::arrayMapMethod($genres, 'toAmpacheApi', [$this->l10n])
1481
		];
1482
	}
1483
1484
	/**
1485
	 * @param Genre[] $genres
1486
	 */
1487
	private function renderGenres(array $genres) : array {
1488
		return [
1489
			'genre' => Util::arrayMapMethod($genres, 'toAmpacheApi', [$this->l10n])
1490
		];
1491
	}
1492
1493
	/**
1494
	 * @param Bookmark[] $bookmarks
1495
	 */
1496
	private function renderBookmarks(array $bookmarks) : array {
1497
		return [
1498
			'bookmark' => Util::arrayMapMethod($bookmarks, 'toAmpacheApi')
1499
		];
1500
	}
1501
1502
	/**
1503
	 * @param Track[] $tracks
1504
	 */
1505
	private function renderSongsIndex(array $tracks) : array {
1506
		return [
1507
			'song' => \array_map(function ($track) {
1508
				return [
1509
					'id' => (string)$track->getId(),
1510
					'title' => $track->getTitle(),
1511
					'name' => $track->getTitle(),
1512
					'artist' => $this->renderAlbumOrArtistRef($track->getArtistId(), $track->getArtistNameString($this->l10n)),
1513
					'album' => $this->renderAlbumOrArtistRef($track->getAlbumId(), $track->getAlbumNameString($this->l10n))
1514
				];
1515
			}, $tracks)
1516
		];
1517
	}
1518
1519
	/**
1520
	 * @param Album[] $albums
1521
	 */
1522
	private function renderAlbumsIndex(array $albums) : array {
1523
		return [
1524
			'album' => \array_map(function ($album) {
1525
				$name = $album->getNameString($this->l10n);
1526
				$nameParts = $this->prefixAndBaseName($name);
1527
1528
				return [
1529
					'id' => (string)$album->getId(),
1530
					'name' => $name,
1531
					'prefix' => $nameParts['prefix'],
1532
					'basename' => $nameParts['basename'],
1533
					'artist' => $this->renderAlbumOrArtistRef($album->getAlbumArtistId(), $album->getAlbumArtistNameString($this->l10n))
1534
				];
1535
			}, $albums)
1536
		];
1537
	}
1538
1539
	/**
1540
	 * @param Artist[] $artists
1541
	 */
1542
	private function renderArtistsIndex(array $artists) : array {
1543
		return [
1544
			'artist' => \array_map(function ($artist) {
1545
				$userId = $this->session->getUserId();
1546
				$albums = $this->albumBusinessLayer->findAllByArtist($artist->getId(), $userId);
1547
				$name = $artist->getNameString($this->l10n);
1548
				$nameParts = $this->prefixAndBaseName($name);
1549
1550
				return [
1551
					'id' => (string)$artist->getId(),
1552
					'name' => $name,
1553
					'prefix' => $nameParts['prefix'],
1554
					'basename' => $nameParts['basename'],
1555
					'album' => \array_map(function ($album) {
1556
						return $this->renderAlbumOrArtistRef($album->getId(), $album->getNameString($this->l10n));
1557
					}, $albums)
1558
				];
1559
			}, $artists)
1560
		];
1561
	}
1562
1563
	/**
1564
	 * @param Playlist[] $playlists
1565
	 */
1566
	private function renderPlaylistsIndex(array $playlists) : array {
1567
		return [
1568
			'playlist' => \array_map(function ($playlist) {
1569
				return [
1570
					'id' => (string)$playlist->getId(),
1571
					'name' => $playlist->getName(),
1572
					'playlisttrack' => $playlist->getTrackIdsAsArray()
1573
				];
1574
			}, $playlists)
1575
		];
1576
	}
1577
1578
	/**
1579
	 * @param PodcastChannel[] $channels
1580
	 */
1581
	private function renderPodcastChannelsIndex(array $channels) : array {
1582
		// The v4 API spec does not give any examples of this, and the v5 example is almost identical to the v4 "normal" result
1583
		return $this->renderPodcastChannels($channels);
1584
	}
1585
1586
	/**
1587
	 * @param PodcastEpisode[] $episodes
1588
	 */
1589
	private function renderPodcastEpisodesIndex(array $episodes) : array {
1590
		// The v4 API spec does not give any examples of this, and the v5 example is almost identical to the v4 "normal" result
1591
		return $this->renderPodcastEpisodes($episodes);
1592
	}
1593
1594
	/**
1595
	 * @param RadioStation[] $stations
1596
	 */
1597
	private function renderLiveStreamsIndex(array $stations) : array {
1598
		// The API spec gives no examples of this, but testing with Ampache demo server revealed that the format is indentical to the "full" format
1599
		return $this->renderLiveStreams($stations);
1600
	}
1601
1602
	/**
1603
	 * @param Entity[] $entities
1604
	 */
1605
	private function renderEntityIds(array $entities) : array {
1606
		return ['id' => Util::extractIds($entities)];
1607
	}
1608
1609
	/**
1610
	 * Array is considered to be "indexed" if its first element has numerical key.
1611
	 * Empty array is considered to be "indexed".
1612
	 */
1613
	private static function arrayIsIndexed(array $array) : bool {
1614
		\reset($array);
1615
		return empty($array) || \is_int(\key($array));
1616
	}
1617
1618
	/**
1619
	 * The JSON API has some asymmetries with the XML API. This function makes the needed
1620
	 * translations for the result content before it is converted into JSON.
1621
	 */
1622
	private function prepareResultForJsonApi(array $content) : array {
1623
		$apiVer = $this->apiMajorVersion();
1624
1625
		// Special handling is needed for responses returning an array of library entities,
1626
		// depending on the API version. In these cases, the outermost array is of associative
1627
		// type with a single value which is a non-associative array.
1628
		if (\count($content) === 1 && !self::arrayIsIndexed($content)
1629
				&& \is_array(\current($content)) && self::arrayIsIndexed(\current($content))) {
1630
			// In API versions < 5, the root node is an anonymous array. Unwrap the outermost array.
1631
			if ($apiVer < 5) {
1632
				$content = \array_pop($content);
1633
			}
1634
			// In later versions, the root object has a named array for plural actions (like "songs", "artists").
1635
			// For singular actions (like "song", "artist"), the root object contains directly the entity properties.
1636
			else {
1637
				$action = $this->request->getParam('action');
1638
				$plural = (\substr($action, -1) === 's' || $action === 'get_similar' || $action === 'advanced_search');
1639
1640
				// In APIv5, the action "album" is an excption, it is formatted as if it was a plural action.
1641
				// This outlier has been fixed in APIv6.
1642
				$api5albumOddity = ($apiVer === 5 && $action === 'album');
1643
1644
				if (!($plural  || $api5albumOddity)) {
1645
					$content = \array_pop($content);
1646
					$content = \array_pop($content);
1647
				}
1648
			}
1649
		}
1650
1651
		// In API versions < 6, all boolean valued properties should be converted to 0/1.
1652
		if ($apiVer < 6) {
1653
			Util::intCastArrayValues($content, 'is_bool');
1654
		}
1655
1656
		// The key 'value' has a special meaning on XML responses, as it makes the corresponding value
1657
		// to be treated as text content of the parent element. In the JSON API, these are mostly
1658
		// substituted with property 'name', but error responses use the property 'message', instead.
1659
		if (\array_key_exists('error', $content)) {
1660
			$content = Util::convertArrayKeys($content, ['value' => 'message']);
1661
		} else {
1662
			$content = Util::convertArrayKeys($content, ['value' => 'name']);
1663
		}
1664
		return $content;
1665
	}
1666
1667
	/**
1668
	 * The XML API has some asymmetries with the JSON API. This function makes the needed
1669
	 * translations for the result content before it is converted into XML.
1670
	 */
1671
	private function prepareResultForXmlApi(array $content) : array {
1672
		\reset($content);
1673
		$firstKey = \key($content);
1674
1675
		// all 'entity list' kind of responses shall have the (deprecated) total_count element
1676
		if ($firstKey == 'song' || $firstKey == 'album' || $firstKey == 'artist' || $firstKey == 'playlist'
1677
				|| $firstKey == 'tag' || $firstKey == 'genre' || $firstKey == 'podcast' || $firstKey == 'podcast_episode'
1678
				|| $firstKey == 'live_stream') {
1679
			$content = ['total_count' => \count($content[$firstKey])] + $content;
1680
		}
1681
1682
		// for some bizarre reason, the 'id' arrays have 'index' attributes in the XML format
1683
		if ($firstKey == 'id') {
1684
			$content['id'] = \array_map(function ($id, $index) {
1685
				return ['index' => $index, 'value' => $id];
1686
			}, $content['id'], \array_keys($content['id']));
1687
		}
1688
1689
		return ['root' => $content];
1690
	}
1691
1692
	private function genreKey() : string {
1693
		return ($this->apiMajorVersion() > 4) ? 'genre' : 'tag';
1694
	}
1695
1696
	private function apiMajorVersion() : int {
1697
		// During the handshake, we don't yet have a session but the requeted version may be in the request args
1698
		$verString = ($this->session !== null) 
1699
			? $this->session->getApiVersion()
1700
			: $this->request->getParam('version');
1701
		
1702
		if (\is_string($verString) && \strlen($verString)) {
1703
			$ver = (int)$verString[0];
1704
		} else {
1705
			// Default version is 6 unless otherwise defined in config.php
1706
			$ver = (int)$this->config->getSystemValue('music.ampache_api_default_ver', 6);
1707
		}
1708
1709
		// For now, we have three supported major versions. Major version 3 can be sufficiently supported
1710
		// with our "version 4" implementation.
1711
		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...
1712
	}
1713
1714
	private function apiVersionString() : string {
1715
		switch ($this->apiMajorVersion()) {
1716
			case 4:		return self::API4_VERSION;
1717
			case 5:		return self::API5_VERSION;
1718
			case 6:		return self::API6_VERSION;
1719
			default:	throw new AmpacheException('Unexpected api major version', 500);
1720
		}
1721
	}
1722
1723
	private function mapApiV4ErrorToV5(int $code) : int {
1724
		switch ($code) {
1725
			case 400:	return 4710;	// bad request
1726
			case 401:	return 4701;	// invalid handshake
1727
			case 403:	return 4703;	// access denied
1728
			case 404:	return 4704;	// not found
1729
			case 405:	return 4705;	// missing
1730
			case 412:	return 4742;	// failed access check
1731
			case 501:	return 4700;	// access control not enabled
1732
			default:	return 5000;	// unexcpected (not part of the API spec)
1733
		}
1734
	}
1735
}
1736
1737
/**
1738
 * Adapter class which acts like the Playlist class for the purpose of
1739
 * AmpacheController::renderPlaylists but contains all the track of the user.
1740
 */
1741
class AmpacheController_AllTracksPlaylist extends Playlist {
1742
	private $trackBusinessLayer;
1743
	private $l10n;
1744
1745
	public function __construct(string $userId, TrackBusinessLayer $trackBusinessLayer, IL10N $l10n) {
1746
		$this->userId = $userId;
1747
		$this->trackBusinessLayer = $trackBusinessLayer;
1748
		$this->l10n = $l10n;
1749
	}
1750
1751
	public function getId() : int {
1752
		return AmpacheController::ALL_TRACKS_PLAYLIST_ID;
1753
	}
1754
1755
	public function getName() : string {
1756
		return $this->l10n->t('All tracks');
1757
	}
1758
1759
	public function getTrackCount() : int {
1760
		return $this->trackBusinessLayer->count($this->userId);
1761
	}
1762
}
1763