Passed
Push — feature/playlist_improvements ( 2a690a...faf9ee )
by Pauli
14:31
created

AmpacheController::xmlApi()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 9
dl 0
loc 3
ccs 0
cts 2
cp 0
crap 2
rs 10

How to fix   Many Parameters   

Many Parameters

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

There are several approaches to avoid long parameter lists:

1
<?php
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 - 2020
13
 */
14
15
namespace OCA\Music\Controller;
16
17
use \OCP\AppFramework\Controller;
0 ignored issues
show
Bug introduced by
The type OCP\AppFramework\Controller was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
18
use \OCP\AppFramework\Http\JSONResponse;
0 ignored issues
show
Bug introduced by
The type OCP\AppFramework\Http\JSONResponse was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
19
use \OCP\IRequest;
0 ignored issues
show
Bug introduced by
The type OCP\IRequest was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
20
use \OCP\IURLGenerator;
0 ignored issues
show
Bug introduced by
The type OCP\IURLGenerator was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
21
22
use \OCA\Music\AppFramework\BusinessLayer\BusinessLayer;
23
use \OCA\Music\AppFramework\BusinessLayer\BusinessLayerException;
24
use \OCA\Music\AppFramework\Core\Logger;
25
use \OCA\Music\Middleware\AmpacheException;
26
27
use \OCA\Music\BusinessLayer\AlbumBusinessLayer;
28
use \OCA\Music\BusinessLayer\ArtistBusinessLayer;
29
use \OCA\Music\BusinessLayer\GenreBusinessLayer;
30
use \OCA\Music\BusinessLayer\Library;
31
use \OCA\Music\BusinessLayer\PlaylistBusinessLayer;
32
use \OCA\Music\BusinessLayer\TrackBusinessLayer;
33
34
use \OCA\Music\Db\Album;
35
use \OCA\Music\Db\AmpacheUserMapper;
36
use \OCA\Music\Db\AmpacheSession;
37
use \OCA\Music\Db\AmpacheSessionMapper;
38
use \OCA\Music\Db\Artist;
39
use \OCA\Music\Db\SortBy;
40
41
use \OCA\Music\Http\ErrorResponse;
42
use \OCA\Music\Http\FileResponse;
43
use \OCA\Music\Http\XMLResponse;
44
45
use \OCA\Music\Utility\AmpacheUser;
46
use \OCA\Music\Utility\CoverHelper;
47
use \OCA\Music\Utility\Util;
48
49
class AmpacheController extends Controller {
50
	private $ampacheUserMapper;
51
	private $ampacheSessionMapper;
52
	private $albumBusinessLayer;
53
	private $artistBusinessLayer;
54
	private $genreBusinessLayer;
55
	private $playlistBusinessLayer;
56
	private $trackBusinessLayer;
57
	private $library;
58
	private $ampacheUser;
59
	private $urlGenerator;
60
	private $rootFolder;
61
	private $l10n;
62
	private $coverHelper;
63
	private $logger;
64
	private $jsonMode;
65
66
	const SESSION_EXPIRY_TIME = 6000;
67
	const ALL_TRACKS_PLAYLIST_ID = 10000000;
68
	const API_VERSION = 350001;
69
70
	public function __construct($appname,
71
								IRequest $request,
72
								$l10n,
73
								IURLGenerator $urlGenerator,
74
								AmpacheUserMapper $ampacheUserMapper,
75
								AmpacheSessionMapper $ampacheSessionMapper,
76
								AlbumBusinessLayer $albumBusinessLayer,
77
								ArtistBusinessLayer $artistBusinessLayer,
78
								GenreBusinessLayer $genreBusinessLayer,
79
								PlaylistBusinessLayer $playlistBusinessLayer,
80
								TrackBusinessLayer $trackBusinessLayer,
81
								Library $library,
82
								AmpacheUser $ampacheUser,
83
								$rootFolder,
84
								CoverHelper $coverHelper,
85
								Logger $logger) {
86
		parent::__construct($appname, $request);
87
88
		$this->ampacheUserMapper = $ampacheUserMapper;
89
		$this->ampacheSessionMapper = $ampacheSessionMapper;
90
		$this->albumBusinessLayer = $albumBusinessLayer;
91
		$this->artistBusinessLayer = $artistBusinessLayer;
92
		$this->genreBusinessLayer = $genreBusinessLayer;
93
		$this->playlistBusinessLayer = $playlistBusinessLayer;
94
		$this->trackBusinessLayer = $trackBusinessLayer;
95
		$this->library = $library;
96
		$this->urlGenerator = $urlGenerator;
97
		$this->l10n = $l10n;
98
99
		// used to share user info with middleware
100
		$this->ampacheUser = $ampacheUser;
101
102
		// used to deliver actual media file
103
		$this->rootFolder = $rootFolder;
104
105
		$this->coverHelper = $coverHelper;
106
		$this->logger = $logger;
107
	}
108
109
	public function setJsonMode($useJsonMode) {
110
		$this->jsonMode = $useJsonMode;
111
	}
112
113
	public function ampacheResponse($content) {
114
		if ($this->jsonMode) {
115
			return new JSONResponse(self::prepareResultForJsonApi($content));
116
		} else {
117
			return new XMLResponse(['root' => $content], ['id', 'count', 'code']);
118
		}
119
	}
120
121
	/**
122
	 * @NoAdminRequired
123
	 * @PublicPage
124
	 * @NoCSRFRequired
125
	 */
126
	public function xmlApi($action, $user, $timestamp, $auth, $filter, $exact, $limit, $offset, $id) {
127
		// differentation between xmlApi and jsonApi is made already by the middleware
128
		return $this->dispatch($action, $user, $timestamp, $auth, $filter, $exact, $limit, $offset, $id);
129
	}
130
131
	/**
132
	 * @NoAdminRequired
133
	 * @PublicPage
134
	 * @NoCSRFRequired
135
	 */
136
	public function jsonApi($action, $user, $timestamp, $auth, $filter, $exact, $limit, $offset, $id) {
137
		// differentation between xmlApi and jsonApi is made already by the middleware
138
		return $this->dispatch($action, $user, $timestamp, $auth, $filter, $exact, $limit, $offset, $id);
139
	}
140
141
	protected function dispatch($action, $user, $timestamp, $auth, $filter, $exact, $limit, $offset, $id) {
142
		$this->logger->log("Ampache action '$action' requested", 'debug');
143
144
		$limit = self::validateLimitOrOffset($limit);
145
		$offset = self::validateLimitOrOffset($offset);
146
147
		switch ($action) {
148
			case 'handshake':
149
				return $this->handshake($user, $timestamp, $auth);
150
			case 'ping':
151
				return $this->ping($auth);
152
			case 'artists':
153
				return $this->artists($filter, $exact, $limit, $offset, $auth);
154
			case 'artist':
155
				return $this->artist($filter, $auth);
156
			case 'artist_albums':
157
				return $this->artist_albums($filter, $auth);
158
			case 'album_songs':
159
				return $this->album_songs($filter, $auth);
160
			case 'albums':
161
				return $this->albums($filter, $exact, $limit, $offset, $auth);
162
			case 'album':
163
				return $this->album($filter, $auth);
164
			case 'artist_songs':
165
				return $this->artist_songs($filter, $auth);
166
			case 'songs':
167
				return $this->songs($filter, $exact, $limit, $offset, $auth);
168
			case 'song':
169
				return $this->song($filter, $auth);
170
			case 'search_songs':
171
				return $this->search_songs($filter, $auth);
172
			case 'playlists':
173
				return $this->playlists($filter, $exact, $limit, $offset);
174
			case 'playlist':
175
				return $this->playlist($filter);
176
			case 'playlist_songs':
177
				return $this->playlist_songs($filter, $limit, $offset, $auth);
178
			case 'tags':
179
				return $this->tags($filter, $exact, $limit, $offset);
180
			case 'tag':
181
				return $this->tag($filter);
182
			case 'tag_artists':
183
				return $this->tag_artists($filter, $limit, $offset, $auth);
184
			case 'tag_albums':
185
				return $this->tag_albums($filter, $limit, $offset, $auth);
186
			case 'tag_songs':
187
				return $this->tag_songs($filter, $limit, $offset, $auth);
188
			case 'download':
189
				return $this->download($id); // args 'type' and 'format' not supported
190
			case 'stream':
191
				return $this->stream($id, $offset); // args 'type', 'bitrate', 'format', and 'length' not supported
192
193
			# non Ampache API actions
194
			case '_get_album_cover':
195
				return $this->_get_album_cover($id);
196
			case '_get_artist_cover':
197
				return $this->_get_artist_cover($id);
198
		}
199
200
		$this->logger->log("Unsupported Ampache action '$action' requested", 'warn');
201
		throw new AmpacheException('Action not supported', 405);
202
	}
203
204
	/***********************
205
	 * Ampahce API methods *
206
	 ***********************/
207
208
	protected function handshake($user, $timestamp, $auth) {
209
		$currentTime = \time();
210
		$expiryDate = $currentTime + self::SESSION_EXPIRY_TIME;
211
212
		$this->checkHandshakeTimestamp($timestamp, $currentTime);
213
		$this->checkHandshakeAuthentication($user, $timestamp, $auth);
214
		$token = $this->startNewSession($user, $expiryDate);
215
216
		$currentTimeFormated = \date('c', $currentTime);
217
		$expiryDateFormated = \date('c', $expiryDate);
218
219
		return $this->ampacheResponse([
220
			'auth' => $token,
221
			'version' => self::API_VERSION,
222
			'update' => $currentTimeFormated,
223
			'add' => $currentTimeFormated,
224
			'clean' => $currentTimeFormated,
225
			'songs' => $this->trackBusinessLayer->count($user),
226
			'artists' => $this->artistBusinessLayer->count($user),
227
			'albums' => $this->albumBusinessLayer->count($user),
228
			'playlists' => $this->playlistBusinessLayer->count($user) + 1, // +1 for "All tracks"
229
			'session_expire' => $expiryDateFormated,
230
			'tags' => $this->genreBusinessLayer->count($user),
231
			'videos' => 0
232
		]);
233
	}
234
235
	protected function ping($auth) {
236
		if ($auth !== null && $auth !== '') {
237
			$this->ampacheSessionMapper->extend($auth, \time() + self::SESSION_EXPIRY_TIME);
238
		}
239
240
		return $this->ampacheResponse([
241
			'version' => self::API_VERSION
242
		]);
243
	}
244
245
	protected function artists($filter, $exact, $limit, $offset, $auth) {
246
		$artists = $this->findEntities($this->artistBusinessLayer, $filter, $exact, $limit, $offset);
247
		return $this->renderArtists($artists, $auth);
248
	}
249
250
	protected function artist($artistId, $auth) {
251
		$userId = $this->ampacheUser->getUserId();
252
		$artist = $this->artistBusinessLayer->find($artistId, $userId);
253
		return $this->renderArtists([$artist], $auth);
254
	}
255
256
	protected function artist_albums($artistId, $auth) {
257
		$userId = $this->ampacheUser->getUserId();
258
		$albums = $this->albumBusinessLayer->findAllByArtist($artistId, $userId);
259
		return $this->renderAlbums($albums, $auth);
260
	}
261
262
	protected function artist_songs($artistId, $auth) {
263
		$userId = $this->ampacheUser->getUserId();
264
		$tracks = $this->trackBusinessLayer->findAllByArtist($artistId, $userId);
265
		$this->injectAlbum($tracks);
266
		return $this->renderSongs($tracks, $auth);
267
	}
268
269
	protected function album_songs($albumId, $auth) {
270
		$userId = $this->ampacheUser->getUserId();
271
272
		$album = $this->albumBusinessLayer->find($albumId, $userId);
273
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $userId);
274
		$this->injectAlbum($tracks, $album);
275
276
		return $this->renderSongs($tracks, $auth);
277
	}
278
279
	protected function song($trackId, $auth) {
280
		$userId = $this->ampacheUser->getUserId();
281
		$track = $this->trackBusinessLayer->find($trackId, $userId);
282
		$trackInArray = [$track];
283
		$this->injectAlbum($trackInArray);
284
		return $this->renderSongs($trackInArray, $auth);
285
	}
286
287
	protected function songs($filter, $exact, $limit, $offset, $auth) {
288
289
		// optimized handling for fetching the whole library
290
		// note: the ordering of the songs differs between these two cases
291
		if (empty($filter) && !$limit && !$offset) {
292
			$tracks = $this->getAllTracks();
293
		}
294
		// general case
295
		else {
296
			$tracks = $this->findEntities($this->trackBusinessLayer, $filter, $exact, $limit, $offset);
297
			$this->injectAlbum($tracks);
298
		}
299
300
		return $this->renderSongs($tracks, $auth);
301
	}
302
303
	protected function search_songs($filter, $auth) {
304
		$userId = $this->ampacheUser->getUserId();
305
		$tracks = $this->trackBusinessLayer->findAllByNameRecursive($filter, $userId);
306
		$this->injectAlbum($tracks);
307
		return $this->renderSongs($tracks, $auth);
308
	}
309
310
	protected function albums($filter, $exact, $limit, $offset, $auth) {
311
		$albums = $this->findEntities($this->albumBusinessLayer, $filter, $exact, $limit, $offset);
312
		return $this->renderAlbums($albums, $auth);
313
	}
314
315
	protected function album($albumId, $auth) {
316
		$userId = $this->ampacheUser->getUserId();
317
		$album = $this->albumBusinessLayer->find($albumId, $userId);
318
		return $this->renderAlbums([$album], $auth);
319
	}
320
321
	protected function playlists($filter, $exact, $limit, $offset) {
322
		$userId = $this->ampacheUser->getUserId();
323
		$playlists = $this->findEntities($this->playlistBusinessLayer, $filter, $exact, $limit, $offset);
324
325
		// append "All tracks" if not searching by name, and it is not off-limit
326
		$allTracksIndex = $this->playlistBusinessLayer->count($userId);
327
		if (empty($filter) && self::indexIsWithinOffsetAndLimit($allTracksIndex, $offset, $limit)) {
328
			$playlists[] = new AmpacheController_AllTracksPlaylist($userId, $this->trackBusinessLayer, $this->l10n);
329
		}
330
331
		return $this->renderPlaylists($playlists);
332
	}
333
334
	protected function playlist($listId) {
335
		$userId = $this->ampacheUser->getUserId();
336
		if ($listId == self::ALL_TRACKS_PLAYLIST_ID) {
337
			$playlist = new AmpacheController_AllTracksPlaylist($userId, $this->trackBusinessLayer, $this->l10n);
338
		} else {
339
			$playlist = $this->playlistBusinessLayer->find($listId, $userId);
340
		}
341
		return $this->renderPlaylists([$playlist]);
342
	}
343
344
	protected function playlist_songs($listId, $limit, $offset, $auth) {
345
		if ($listId == self::ALL_TRACKS_PLAYLIST_ID) {
346
			$playlistTracks = $this->getAllTracks();
347
			$playlistTracks = \array_slice($playlistTracks, $offset, $limit);
348
		}
349
		else {
350
			$userId = $this->ampacheUser->getUserId();
351
			$playlistTracks = $this->playlistBusinessLayer->getPlaylistTracks($listId, $userId, $limit, $offset);
352
			$this->injectAlbum($playlistTracks);
353
		}
354
		return $this->renderSongs($playlistTracks, $auth);
355
	}
356
357
	protected function tags($filter, $exact, $limit, $offset) {
358
		$genres = $this->findEntities($this->genreBusinessLayer, $filter, $exact, $limit, $offset);
359
		return $this->renderTags($genres);
360
	}
361
362
	protected function tag($tagId) {
363
		$userId = $this->ampacheUser->getUserId();
364
		$genre = $this->genreBusinessLayer->find($tagId, $userId);
365
		return $this->renderTags([$genre]);
366
	}
367
368
	protected function tag_artists($genreId, $limit, $offset, $auth) {
369
		$userId = $this->ampacheUser->getUserId();
370
		$artists = $this->artistBusinessLayer->findAllByGenre($genreId, $userId, $limit, $offset);
371
		return $this->renderArtists($artists, $auth);
372
	}
373
374
	protected function tag_albums($genreId, $limit, $offset, $auth) {
375
		$userId = $this->ampacheUser->getUserId();
376
		$albums = $this->albumBusinessLayer->findAllByGenre($genreId, $userId, $limit, $offset);
377
		return $this->renderAlbums($albums, $auth);
378
	}
379
380
	protected function tag_songs($genreId, $limit, $offset, $auth) {
381
		$userId = $this->ampacheUser->getUserId();
382
		$tracks = $this->trackBusinessLayer->findAllByGenre($genreId, $userId, $limit, $offset);
383
		$this->injectAlbum($tracks);
384
		return $this->renderSongs($tracks, $auth);
385
	}
386
387
	protected function download($trackId) {
388
		$userId = $this->ampacheUser->getUserId();
389
390
		try {
391
			$track = $this->trackBusinessLayer->find($trackId, $userId);
392
		} catch (BusinessLayerException $e) {
393
			return new ErrorResponse(Http::STATUS_NOT_FOUND, $e->getMessage());
0 ignored issues
show
Bug introduced by
The type OCA\Music\Controller\Http was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
394
		}
395
396
		$files = $this->rootFolder->getUserFolder($userId)->getById($track->getFileId());
397
398
		if (\count($files) === 1) {
399
			return new FileResponse($files[0]);
400
		} else {
401
			return new ErrorResponse(Http::STATUS_NOT_FOUND);
402
		}
403
	}
404
405
	protected function stream($trackId, $offset) {
406
		// This is just a dummy implementation. We don't support transcoding or streaming
407
		// from a time offset.
408
		// All the other unsupported arguments are just ignored, but a request with an offset
409
		// is responded with an error. This is becuase the client would probably work in an
410
		// unexpected way if it thinks it's streaming from offset but actually it is streaming
411
		// from the beginning of the file. Returning an error gives the client a chance to fallback
412
		// to other methods of seeking.
413
		if ($offset !== null) {
414
			throw new AmpacheException('Streaming with time offset is not supported', 405);
415
		}
416
417
		return $this->download($trackId);
418
	}
419
420
	/***************************************************************
421
	 * API methods which are not part of the Ampache specification *
422
	 ***************************************************************/
423
	protected function _get_album_cover($albumId) {
424
		return $this->getCover($albumId, $this->albumBusinessLayer);
425
	}
426
427
	protected function _get_artist_cover($artistId) {
428
		return $this->getCover($artistId, $this->artistBusinessLayer);
429
	}
430
431
432
	/********************
433
	 * Helper functions *
434
	 ********************/
435
436
	private function getCover($entityId, BusinessLayer $businessLayer) {
437
		$userId = $this->ampacheUser->getUserId();
438
		$userFolder = $this->rootFolder->getUserFolder($userId);
439
		$entity = $businessLayer->find($entityId, $userId);
440
441
		try {
442
			$coverData = $this->coverHelper->getCover($entity, $userId, $userFolder);
443
			if ($coverData !== null) {
444
				return new FileResponse($coverData);
445
			}
446
		} catch (BusinessLayerException $e) {
447
			return new ErrorResponse(Http::STATUS_NOT_FOUND, 'entity not found');
448
		}
449
450
		return new ErrorResponse(Http::STATUS_NOT_FOUND, 'entity has no cover');
451
	}
452
453
	private function checkHandshakeTimestamp($timestamp, $currentTime) {
454
		$providedTime = \intval($timestamp);
455
456
		if ($providedTime === 0) {
457
			throw new AmpacheException('Invalid Login - cannot parse time', 401);
458
		}
459
		if ($providedTime < ($currentTime - self::SESSION_EXPIRY_TIME)) {
460
			throw new AmpacheException('Invalid Login - session is outdated', 401);
461
		}
462
		// Allow the timestamp to be at maximum 10 minutes in the future. The client may use its
463
		// own system clock to generate the timestamp and that may differ from the server's time.
464
		if ($providedTime > $currentTime + 600) {
465
			throw new AmpacheException('Invalid Login - timestamp is in future', 401);
466
		}
467
	}
468
469
	private function checkHandshakeAuthentication($user, $timestamp, $auth) {
470
		$hashes = $this->ampacheUserMapper->getPasswordHashes($user);
471
472
		foreach ($hashes as $hash) {
473
			$expectedHash = \hash('sha256', $timestamp . $hash);
474
475
			if ($expectedHash === $auth) {
476
				return;
477
			}
478
		}
479
480
		throw new AmpacheException('Invalid Login - passphrase does not match', 401);
481
	}
482
483
	private function startNewSession($user, $expiryDate) {
484
		// this can cause collision, but it's just a temporary token
485
		$token = \md5(\uniqid(\rand(), true));
486
487
		// create new session
488
		$session = new AmpacheSession();
489
		$session->setUserId($user);
490
		$session->setToken($token);
491
		$session->setExpiry($expiryDate);
492
493
		// save session
494
		$this->ampacheSessionMapper->insert($session);
495
496
		return $token;
497
	}
498
499
	private function findEntities(BusinessLayer $businessLayer, $filter, $exact, $limit=null, $offset=null) {
500
		$userId = $this->ampacheUser->getUserId();
501
502
		if ($filter) {
503
			$fuzzy = !((boolean) $exact);
504
			return $businessLayer->findAllByName($filter, $userId, $fuzzy, $limit, $offset);
505
		} else {
506
			return $businessLayer->findAll($userId, SortBy::Name, $limit, $offset);
507
		}
508
	}
509
510
	/**
511
	 * Getting all tracks with this helper is more efficient than with `findEntities`
512
	 * followed by `injectAlbum`. This is because, under the hood, the albums
513
	 * are fetched with a single DB query instead of fetching each separately.
514
	 * 
515
	 * The result set is ordered first by artist and then by song title.
516
	 */
517
	private function getAllTracks() {
518
		$userId = $this->ampacheUser->getUserId();
519
		$tracks = $this->library->getTracksAlbumsAndArtists($userId)['tracks'];
520
		\usort($tracks, ['\OCA\Music\Db\Track', 'compareArtistAndTitle']);
521
		foreach ($tracks as $index => &$track) {
522
			$track->setNumberOnPlaylist($index + 1);
523
		}
524
		return $tracks;
525
	}
526
527
	private function createAmpacheActionUrl($action, $id, $auth) {
528
		$api = $this->jsonMode ? 'music.ampache.jsonApi' : 'music.ampache.xmlApi';
529
		return $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute($api))
530
				. "?action=$action&id=$id&auth=$auth";
531
	}
532
533
	private function createCoverUrl($entity, $auth) {
534
		if ($entity instanceof Album) {
535
			$type = 'album';
536
		} elseif ($entity instanceof Artist) {
537
			$type = 'artist';
538
		} else {
539
			throw new AmpacheException('unexpeted entity type for cover image', 500);
540
		}
541
542
		if ($entity->getCoverFileId()) {
543
			return $this->createAmpacheActionUrl("_get_{$type}_cover", $entity->getId(), $auth);
544
		} else {
545
			return '';
546
		}
547
	}
548
549
	/**
550
	 * Any non-integer values and integer value 0 are converted to null to
551
	 * indicate "no limit" or "no offset".
552
	 * @param string $value
553
	 * @return integer|null
554
	 */
555
	private static function validateLimitOrOffset($value) {
556
		if (\ctype_digit(\strval($value)) && $value !== 0) {
557
			return \intval($value);
558
		} else {
559
			return null;
560
		}
561
	}
562
563
	/**
564
	 * @param int $index
565
	 * @param int|null $offset
566
	 * @param int|null $limit
567
	 * @return boolean
568
	 */
569
	private static function indexIsWithinOffsetAndLimit($index, $offset, $limit) {
570
		$offset = \intval($offset); // missing offset is interpreted as 0-offset
571
		return ($limit === null) || ($index >= $offset && $index < $offset + $limit);
572
	}
573
574
	private function renderArtists($artists, $auth) {
575
		$userId = $this->ampacheUser->getUserId();
576
		$genreMap = Util::createIdLookupTable($this->genreBusinessLayer->findAll($userId));
577
578
		return $this->ampacheResponse([
579
			'artist' => \array_map(function($artist) use ($userId, $genreMap, $auth) {
580
				return [
581
					'id' => (string)$artist->getId(),
582
					'name' => $artist->getNameString($this->l10n),
583
					'albums' => $this->albumBusinessLayer->countByArtist($artist->getId()),
584
					'songs' => $this->trackBusinessLayer->countByArtist($artist->getId()),
585
					'art' => $this->createCoverUrl($artist, $auth),
586
					'rating' => 0,
587
					'preciserating' => 0,
588
					'tag' => \array_map(function($genreId) use ($genreMap) {
589
						return [
590
							'id' => (string)$genreId,
591
							'value' => $genreMap[$genreId]->getNameString($this->l10n),
592
							'count' => 1
593
						];
594
					}, $this->trackBusinessLayer->getGenresByArtistId($artist->getId(), $userId))
595
				];
596
			}, $artists)
597
		]);
598
	}
599
600
	private function renderAlbums($albums, $auth) {
601
		$userId = $this->ampacheUser->getUserId();
602
603
		$genreMap = Util::createIdLookupTable($this->genreBusinessLayer->findAll($userId));
604
605
		return $this->ampacheResponse([
606
			'album' => \array_map(function($album) use ($auth, $genreMap) {
607
				return [
608
					'id' => (string)$album->getId(),
609
					'name' => $album->getNameString($this->l10n),
610
					'artist' => [
611
						'id' => (string)$album->getAlbumArtistId(),
612
						'value' => $album->getAlbumArtistNameString($this->l10n)
613
					],
614
					'tracks' => $this->trackBusinessLayer->countByAlbum($album->getId()),
615
					'rating' => 0,
616
					'year' => $album->yearToAPI(),
617
					'art' => $this->createCoverUrl($album, $auth),
618
					'preciserating' => 0,
619
					'tag' => \array_map(function($genreId) use ($genreMap) {
620
						return [
621
							'id' => (string)$genreId,
622
							'value' => $genreMap[$genreId]->getNameString($this->l10n),
623
							'count' => 1
624
						];
625
					}, $album->getGenres())
626
				];
627
			}, $albums)
628
		]);
629
	}
630
631
	private function injectAlbum(&$tracks, $commonAlbum=null) {
632
		$userId = $this->ampacheUser->getUserId();
633
634
		foreach ($tracks as &$track) {
635
			if (!empty($commonAlbum)) {
636
				$track->setAlbum($commonAlbum);
637
			} else {
638
				$track->setAlbum($this->albumBusinessLayer->find($track->getAlbumId(), $userId));
639
			}
640
		}
641
	}
642
643
	private function renderSongs($tracks, $auth) {
644
		return $this->ampacheResponse([
645
			'song' => \array_map(function($track) use ($auth) {
646
				$album = $track->getAlbum();
647
648
				$result = [
649
					'id' => (string)$track->getId(),
650
					'title' => $track->getTitle(),
651
					'artist' => [
652
						'id' => (string)$track->getArtistId(),
653
						'value' => $track->getArtistNameString($this->l10n)
654
					],
655
					'albumartist' => [
656
						'id' => (string)$album->getAlbumArtistId(),
657
						'value' => $album->getAlbumArtistNameString($this->l10n)
658
					],
659
					'album' => [
660
						'id' => (string)$album->getId(),
661
						'value' => $album->getNameString($this->l10n)
662
					],
663
					'url' => $this->createAmpacheActionUrl('download', $track->getId(), $auth),
664
					'time' => $track->getLength(),
665
					'year' => $track->getYear(),
666
					'track' => $track->getAdjustedTrackNumber(),
667
					'bitrate' => $track->getBitrate(),
668
					'mime' => $track->getMimetype(),
669
					'size' => $track->getSize(),
670
					'art' => $this->createCoverUrl($album, $auth),
671
					'rating' => 0,
672
					'preciserating' => 0,
673
				];
674
675
				$genreId = $track->getGenreId();
676
				if ($genreId !== null) {
677
					$result['tag'] = [[
678
						'id' => (string)$genreId,
679
						'value' => $track->getGenreNameString($this->l10n),
680
						'count' => 1
681
					]];
682
				}
683
				return $result;
684
			}, $tracks)
685
		]);
686
	}
687
688
	private function renderPlaylists($playlists) {
689
		return $this->ampacheResponse([
690
			'playlist' => \array_map(function($playlist) {
691
				return [
692
					'id' => (string)$playlist->getId(),
693
					'name' => $playlist->getName(),
694
					'owner' => $this->ampacheUser->getUserId(),
695
					'items' => $playlist->getTrackCount(),
696
					'type' => 'Private'
697
				];
698
			}, $playlists)
699
		]);
700
	}
701
702
	private function renderTags($genres) {
703
		return $this->ampacheResponse([
704
			'tag' => \array_map(function($genre) {
705
				return [
706
					'id' => (string)$genre->getId(),
707
					'name' => $genre->getNameString($this->l10n),
708
					'albums' => $genre->getAlbumCount(),
709
					'artists' => $genre->getArtistCount(),
710
					'songs' => $genre->getTrackCount(),
711
					'videos' => 0,
712
					'playlists' => 0,
713
					'stream' => 0
714
				];
715
			}, $genres)
716
		]);
717
	}
718
719
	/**
720
	 * Array is considered to be "indexed" if its first element has numerical key.
721
	 * Empty array is considered to be "indexed".
722
	 * @param array $array
723
	 */
724
	private static function arrayIsIndexed(array $array) {
725
		reset($array);
726
		return empty($array) || \is_int(key($array));
727
	}
728
729
	/**
730
	 * The JSON API has some asymmetries with the JSON API. This function makes the needed
731
	 * translations for the result content before it is converted into JSON. 
732
	 * @param array $content
733
	 * @return array
734
	 */
735
	private static function prepareResultForJsonApi($content) {
736
		// In all responses returning an array of library entities, the root node is anonymous.
737
		// Unwrap the outermost array if it is an associative array with a single array-type value.
738
		if (\count($content) === 1 && !self::arrayIsIndexed($content)
739
				&& \is_array(\current($content)) && self::arrayIsIndexed(\current($content))) {
740
			$content = \array_pop($content);
741
		}
742
743
		// The key 'value' has a special meaning on XML responses, as it makes the corresponding value
744
		// to be treated as text content of the parent element. In the JSON API, these are mostly
745
		// substituted with property 'name', but error responses use the property 'message', instead.
746
		if (\array_key_exists('error', $content)) {
747
			$content = Util::convertArrayKeys($content, ['value' => 'message']);
748
		} else {
749
			$content = Util::convertArrayKeys($content, ['value' => 'name']);
750
		}
751
		return $content;
752
	}
753
}
754
755
/**
756
 * Adapter class which acts like the Playlist class for the purpose of 
757
 * AmpacheController::renderPlaylists but contains all the track of the user. 
758
 */
759
class AmpacheController_AllTracksPlaylist {
760
761
	private $user;
762
	private $trackBusinessLayer;
763
	private $l10n;
764
765
	public function __construct($user, $trackBusinessLayer, $l10n) {
766
		$this->user = $user;
767
		$this->trackBusinessLayer = $trackBusinessLayer;
768
		$this->l10n = $l10n;
769
	}
770
771
	public function getId() {
772
		return AmpacheController::ALL_TRACKS_PLAYLIST_ID;
773
	}
774
775
	public function getName() {
776
		return $this->l10n->t('All tracks');
777
	}
778
779
	public function getTrackCount() {
780
		return $this->trackBusinessLayer->count($this->user);
781
	}
782
}
783