Passed
Pull Request — master (#1078)
by Pauli
04:56
created

AmpacheController::dispatch()   B

Complexity

Conditions 6
Paths 5

Size

Total Lines 37
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

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

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

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

1415
			return $this->createCoverUrl(/** @scrutinizer ignore-type */ $track->getAlbum());
Loading history...
1416
		};
1417
		$renderRef = function(int $id, string $name) : array {
1418
			return $this->renderAlbumOrArtistRef($id, $name);
1419
		};
1420
		$genreKey = $this->genreKey();
1421
		// In APIv6 JSON format, there is a new property `artists` with an array value
1422
		$includeArtists = ($this->jsonMode && $this->apiMajorVersion() > 5);
1423
1424
		return [
1425
			'song' => Util::arrayMapMethod($tracks, 'toAmpacheApi', 
1426
				[$this->l10n, $createPlayUrl, $createImageUrl, $renderRef, $genreKey, $includeArtists])
1427
		];
1428
	}
1429
1430
	/**
1431
	 * @param Playlist[] $playlists
1432
	 */
1433
	private function renderPlaylists(array $playlists) : array {
1434
		$createImageUrl = function(Playlist $playlist) : string {
1435
			if ($playlist->getId() === self::ALL_TRACKS_PLAYLIST_ID) {
1436
				return '';
1437
			} else {
1438
				return $this->createCoverUrl($playlist);
1439
			}
1440
		};
1441
1442
		return [
1443
			'playlist' => Util::arrayMapMethod($playlists, 'toAmpacheApi', [$createImageUrl])
1444
		];
1445
	}
1446
1447
	/**
1448
	 * @param PodcastChannel[] $channels
1449
	 */
1450
	private function renderPodcastChannels(array $channels) : array {
1451
		return [
1452
			'podcast' => Util::arrayMapMethod($channels, 'toAmpacheApi')
1453
		];
1454
	}
1455
1456
	/**
1457
	 * @param PodcastEpisode[] $episodes
1458
	 */
1459
	private function renderPodcastEpisodes(array $episodes) : array {
1460
		return [
1461
			'podcast_episode' => Util::arrayMapMethod($episodes, 'toAmpacheApi')
1462
		];
1463
	}
1464
1465
	/**
1466
	 * @param RadioStation[] $stations
1467
	 */
1468
	private function renderLiveStreams(array $stations) : array {
1469
		return [
1470
			'live_stream' => Util::arrayMapMethod($stations, 'toAmpacheApi')
1471
		];
1472
	}
1473
1474
	/**
1475
	 * @param Genre[] $genres
1476
	 */
1477
	private function renderTags(array $genres) : array {
1478
		return [
1479
			'tag' => Util::arrayMapMethod($genres, 'toAmpacheApi', [$this->l10n])
1480
		];
1481
	}
1482
1483
	/**
1484
	 * @param Genre[] $genres
1485
	 */
1486
	private function renderGenres(array $genres) : array {
1487
		return [
1488
			'genre' => Util::arrayMapMethod($genres, 'toAmpacheApi', [$this->l10n])
1489
		];
1490
	}
1491
1492
	/**
1493
	 * @param Bookmark[] $bookmarks
1494
	 */
1495
	private function renderBookmarks(array $bookmarks) : array {
1496
		return [
1497
			'bookmark' => Util::arrayMapMethod($bookmarks, 'toAmpacheApi')
1498
		];
1499
	}
1500
1501
	/**
1502
	 * @param Track[] $tracks
1503
	 */
1504
	private function renderSongsIndex(array $tracks) : array {
1505
		return [
1506
			'song' => \array_map(function ($track) {
1507
				return [
1508
					'id' => (string)$track->getId(),
1509
					'title' => $track->getTitle(),
1510
					'name' => $track->getTitle(),
1511
					'artist' => $this->renderAlbumOrArtistRef($track->getArtistId(), $track->getArtistNameString($this->l10n)),
1512
					'album' => $this->renderAlbumOrArtistRef($track->getAlbumId(), $track->getAlbumNameString($this->l10n))
1513
				];
1514
			}, $tracks)
1515
		];
1516
	}
1517
1518
	/**
1519
	 * @param Album[] $albums
1520
	 */
1521
	private function renderAlbumsIndex(array $albums) : array {
1522
		return [
1523
			'album' => \array_map(function ($album) {
1524
				$name = $album->getNameString($this->l10n);
1525
				$nameParts = $this->prefixAndBaseName($name);
1526
1527
				return [
1528
					'id' => (string)$album->getId(),
1529
					'name' => $name,
1530
					'prefix' => $nameParts['prefix'],
1531
					'basename' => $nameParts['basename'],
1532
					'artist' => $this->renderAlbumOrArtistRef($album->getAlbumArtistId(), $album->getAlbumArtistNameString($this->l10n))
1533
				];
1534
			}, $albums)
1535
		];
1536
	}
1537
1538
	/**
1539
	 * @param Artist[] $artists
1540
	 */
1541
	private function renderArtistsIndex(array $artists) : array {
1542
		return [
1543
			'artist' => \array_map(function ($artist) {
1544
				$userId = $this->session->getUserId();
1545
				$albums = $this->albumBusinessLayer->findAllByArtist($artist->getId(), $userId);
1546
				$name = $artist->getNameString($this->l10n);
1547
				$nameParts = $this->prefixAndBaseName($name);
1548
1549
				return [
1550
					'id' => (string)$artist->getId(),
1551
					'name' => $name,
1552
					'prefix' => $nameParts['prefix'],
1553
					'basename' => $nameParts['basename'],
1554
					'album' => \array_map(function ($album) {
1555
						return $this->renderAlbumOrArtistRef($album->getId(), $album->getNameString($this->l10n));
1556
					}, $albums)
1557
				];
1558
			}, $artists)
1559
		];
1560
	}
1561
1562
	/**
1563
	 * @param Playlist[] $playlists
1564
	 */
1565
	private function renderPlaylistsIndex(array $playlists) : array {
1566
		return [
1567
			'playlist' => \array_map(function ($playlist) {
1568
				return [
1569
					'id' => (string)$playlist->getId(),
1570
					'name' => $playlist->getName(),
1571
					'playlisttrack' => $playlist->getTrackIdsAsArray()
1572
				];
1573
			}, $playlists)
1574
		];
1575
	}
1576
1577
	/**
1578
	 * @param PodcastChannel[] $channels
1579
	 */
1580
	private function renderPodcastChannelsIndex(array $channels) : array {
1581
		// The v4 API spec does not give any examples of this, and the v5 example is almost identical to the v4 "normal" result
1582
		return $this->renderPodcastChannels($channels);
1583
	}
1584
1585
	/**
1586
	 * @param PodcastEpisode[] $episodes
1587
	 */
1588
	private function renderPodcastEpisodesIndex(array $episodes) : array {
1589
		// The v4 API spec does not give any examples of this, and the v5 example is almost identical to the v4 "normal" result
1590
		return $this->renderPodcastEpisodes($episodes);
1591
	}
1592
1593
	/**
1594
	 * @param RadioStation[] $stations
1595
	 */
1596
	private function renderLiveStreamsIndex(array $stations) : array {
1597
		// The API spec gives no examples of this, but testing with Ampache demo server revealed that the format is indentical to the "full" format
1598
		return $this->renderLiveStreams($stations);
1599
	}
1600
1601
	/**
1602
	 * @param Entity[] $entities
1603
	 */
1604
	private function renderEntityIds(array $entities) : array {
1605
		return ['id' => Util::extractIds($entities)];
1606
	}
1607
1608
	/**
1609
	 * Array is considered to be "indexed" if its first element has numerical key.
1610
	 * Empty array is considered to be "indexed".
1611
	 */
1612
	private static function arrayIsIndexed(array $array) : bool {
1613
		\reset($array);
1614
		return empty($array) || \is_int(\key($array));
1615
	}
1616
1617
	/**
1618
	 * The JSON API has some asymmetries with the XML API. This function makes the needed
1619
	 * translations for the result content before it is converted into JSON.
1620
	 */
1621
	private function prepareResultForJsonApi(array $content) : array {
1622
		$apiVer = $this->apiMajorVersion();
1623
1624
		// Special handling is needed for responses returning an array of library entities,
1625
		// depending on the API version. In these cases, the outermost array is of associative
1626
		// type with a single value which is a non-associative array.
1627
		if (\count($content) === 1 && !self::arrayIsIndexed($content)
1628
				&& \is_array(\current($content)) && self::arrayIsIndexed(\current($content))) {
1629
			// In API versions < 5, the root node is an anonymous array. Unwrap the outermost array.
1630
			if ($apiVer < 5) {
1631
				$content = \array_pop($content);
1632
			}
1633
			// In later versions, the root object has a named array for plural actions (like "songs", "artists").
1634
			// For singular actions (like "song", "artist"), the root object contains directly the entity properties.
1635
			else {
1636
				$action = $this->request->getParam('action');
1637
				$plural = (\substr($action, -1) === 's' || $action === 'get_similar' || $action === 'advanced_search');
1638
1639
				// In APIv5, the action "album" is an excption, it is formatted as if it was a plural action.
1640
				// This outlier has been fixed in APIv6.
1641
				$api5albumOddity = ($apiVer === 5 && $action === 'album');
1642
1643
				if (!($plural  || $api5albumOddity)) {
1644
					$content = \array_pop(\array_pop($content));
0 ignored issues
show
Bug introduced by
array_pop($content) cannot be passed to array_pop() as the parameter $array expects a reference. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1644
					$content = \array_pop(/** @scrutinizer ignore-type */ \array_pop($content));
Loading history...
1645
				}
1646
			}
1647
		}
1648
1649
		// In API versions < 6, all boolean valued properties should be converted to 0/1.
1650
		if ($apiVer < 6) {
1651
			Util::intCastArrayValues($content, 'is_bool');
1652
		}
1653
1654
		// The key 'value' has a special meaning on XML responses, as it makes the corresponding value
1655
		// to be treated as text content of the parent element. In the JSON API, these are mostly
1656
		// substituted with property 'name', but error responses use the property 'message', instead.
1657
		if (\array_key_exists('error', $content)) {
1658
			$content = Util::convertArrayKeys($content, ['value' => 'message']);
1659
		} else {
1660
			$content = Util::convertArrayKeys($content, ['value' => 'name']);
1661
		}
1662
		return $content;
1663
	}
1664
1665
	/**
1666
	 * The XML API has some asymmetries with the JSON API. This function makes the needed
1667
	 * translations for the result content before it is converted into XML.
1668
	 */
1669
	private function prepareResultForXmlApi(array $content) : array {
1670
		\reset($content);
1671
		$firstKey = \key($content);
1672
1673
		// all 'entity list' kind of responses shall have the (deprecated) total_count element
1674
		if ($firstKey == 'song' || $firstKey == 'album' || $firstKey == 'artist' || $firstKey == 'playlist'
1675
				|| $firstKey == 'tag' || $firstKey == 'genre' || $firstKey == 'podcast' || $firstKey == 'podcast_episode'
1676
				|| $firstKey == 'live_stream') {
1677
			$content = ['total_count' => \count($content[$firstKey])] + $content;
1678
		}
1679
1680
		// for some bizarre reason, the 'id' arrays have 'index' attributes in the XML format
1681
		if ($firstKey == 'id') {
1682
			$content['id'] = \array_map(function ($id, $index) {
1683
				return ['index' => $index, 'value' => $id];
1684
			}, $content['id'], \array_keys($content['id']));
1685
		}
1686
1687
		return ['root' => $content];
1688
	}
1689
1690
	private function genreKey() : string {
1691
		return ($this->apiMajorVersion() > 4) ? 'genre' : 'tag';
1692
	}
1693
1694
	private function apiMajorVersion() : int {
1695
		// During the handshake, we don't yet have a session but the requeted version may be in the request args
1696
		$verString = ($this->session !== null) 
1697
			? $this->session->getApiVersion()
1698
			: $this->request->getParam('version');
1699
		
1700
		if (\is_string($verString) && \strlen($verString)) {
1701
			$ver = (int)$verString[0];
1702
		} else {
1703
			// Default version is 6 unless otherwise defined in config.php
1704
			$ver = (int)$this->config->getSystemValue('music.ampache_api_default_ver', 6);
1705
		}
1706
1707
		// For now, we have three supported major versions. Major version 3 can be sufficiently supported
1708
		// with our "version 4" implementation.
1709
		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...
1710
	}
1711
1712
	private function apiVersionString() : string {
1713
		switch ($this->apiMajorVersion()) {
1714
			case 4:		return self::API4_VERSION;
1715
			case 5:		return self::API5_VERSION;
1716
			case 6:		return self::API6_VERSION;
1717
			default:	throw new AmpacheException('Unexpected api major version', 500);
1718
		}
1719
	}
1720
1721
	private function mapApiV4ErrorToV5(int $code) : int {
1722
		switch ($code) {
1723
			case 400:	return 4710;	// bad request
1724
			case 401:	return 4701;	// invalid handshake
1725
			case 403:	return 4703;	// access denied
1726
			case 404:	return 4704;	// not found
1727
			case 405:	return 4705;	// missing
1728
			case 412:	return 4742;	// failed access check
1729
			case 501:	return 4700;	// access control not enabled
1730
			default:	return 5000;	// unexcpected (not part of the API spec)
1731
		}
1732
	}
1733
}
1734
1735
/**
1736
 * Adapter class which acts like the Playlist class for the purpose of
1737
 * AmpacheController::renderPlaylists but contains all the track of the user.
1738
 */
1739
class AmpacheController_AllTracksPlaylist extends Playlist {
1740
	private $trackBusinessLayer;
1741
	private $l10n;
1742
1743
	public function __construct(string $userId, TrackBusinessLayer $trackBusinessLayer, IL10N $l10n) {
1744
		$this->userId = $userId;
1745
		$this->trackBusinessLayer = $trackBusinessLayer;
1746
		$this->l10n = $l10n;
1747
	}
1748
1749
	public function getId() : int {
1750
		return AmpacheController::ALL_TRACKS_PLAYLIST_ID;
1751
	}
1752
1753
	public function getName() : string {
1754
		return $this->l10n->t('All tracks');
1755
	}
1756
1757
	public function getTrackCount() : int {
1758
		return $this->trackBusinessLayer->count($this->userId);
1759
	}
1760
}
1761