Passed
Push — master ( df34b5...42f34d )
by Pauli
01:50
created

AmpacheController::checkHandshakeAuthentication()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 12
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 12

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 6
c 1
b 0
f 0
nc 3
nop 3
dl 0
loc 12
ccs 0
cts 7
cp 0
crap 12
rs 10
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
		$artist = $this->artistBusinessLayer->find($artistId, $userId);
265
		$tracks = $this->trackBusinessLayer->findAllByArtist($artistId, $userId);
266
		$this->injectArtistAndAlbum($tracks, $artist);
267
		return $this->renderSongs($tracks, $auth);
268
	}
269
270
	protected function album_songs($albumId, $auth) {
271
		$userId = $this->ampacheUser->getUserId();
272
273
		$album = $this->albumBusinessLayer->find($albumId, $userId);
274
		$album->setAlbumArtist($this->artistBusinessLayer->find($album->getAlbumArtistId(), $userId));
275
276
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $userId);
277
		$this->injectArtistAndAlbum($tracks, null, $album);
278
279
		return $this->renderSongs($tracks, $auth);
280
	}
281
282
	protected function song($trackId, $auth) {
283
		$userId = $this->ampacheUser->getUserId();
284
		$track = $this->trackBusinessLayer->find($trackId, $userId);
285
		$trackInArray = [$track];
286
		$this->injectArtistAndAlbum($trackInArray);
287
		return $this->renderSongs($trackInArray, $auth);
288
	}
289
290
	protected function songs($filter, $exact, $limit, $offset, $auth) {
291
292
		// optimized handling for fetching the whole library
293
		// note: the ordering of the songs differs between these two cases
294
		if (empty($filter) && !$limit && !$offset) {
295
			$tracks = $this->getAllTracks();
296
		}
297
		// general case
298
		else {
299
			$tracks = $this->findEntities($this->trackBusinessLayer, $filter, $exact, $limit, $offset);
300
			$this->injectArtistAndAlbum($tracks);
301
		}
302
303
		return $this->renderSongs($tracks, $auth);
304
	}
305
306
	protected function search_songs($filter, $auth) {
307
		$userId = $this->ampacheUser->getUserId();
308
		$tracks = $this->trackBusinessLayer->findAllByNameRecursive($filter, $userId);
309
		$this->injectArtistAndAlbum($tracks);
310
		return $this->renderSongs($tracks, $auth);
311
	}
312
313
	protected function albums($filter, $exact, $limit, $offset, $auth) {
314
		$albums = $this->findEntities($this->albumBusinessLayer, $filter, $exact, $limit, $offset);
315
		return $this->renderAlbums($albums, $auth);
316
	}
317
318
	protected function album($albumId, $auth) {
319
		$userId = $this->ampacheUser->getUserId();
320
		$album = $this->albumBusinessLayer->find($albumId, $userId);
321
		return $this->renderAlbums([$album], $auth);
322
	}
323
324
	protected function playlists($filter, $exact, $limit, $offset) {
325
		$userId = $this->ampacheUser->getUserId();
326
		$playlists = $this->findEntities($this->playlistBusinessLayer, $filter, $exact, $limit, $offset);
327
328
		// append "All tracks" if not searching by name, and it is not off-limit
329
		$allTracksIndex = $this->playlistBusinessLayer->count($userId);
330
		if (empty($filter) && self::indexIsWithinOffsetAndLimit($allTracksIndex, $offset, $limit)) {
331
			$playlists[] = new AmpacheController_AllTracksPlaylist($userId, $this->trackBusinessLayer, $this->l10n);
332
		}
333
334
		return $this->renderPlaylists($playlists);
335
	}
336
337
	protected function playlist($listId) {
338
		$userId = $this->ampacheUser->getUserId();
339
		if ($listId == self::ALL_TRACKS_PLAYLIST_ID) {
340
			$playlist = new AmpacheController_AllTracksPlaylist($userId, $this->trackBusinessLayer, $this->l10n);
341
		} else {
342
			$playlist = $this->playlistBusinessLayer->find($listId, $userId);
343
		}
344
		return $this->renderPlaylists([$playlist]);
345
	}
346
347
	protected function playlist_songs($listId, $limit, $offset, $auth) {
348
		if ($listId == self::ALL_TRACKS_PLAYLIST_ID) {
349
			$playlistTracks = $this->getAllTracks();
350
			$playlistTracks = \array_slice($playlistTracks, $offset, $limit);
351
		}
352
		else {
353
			$userId = $this->ampacheUser->getUserId();
354
			$playlistTracks = $this->playlistBusinessLayer->getPlaylistTracks($listId, $userId, $limit, $offset);
355
			$this->injectArtistAndAlbum($playlistTracks);
356
		}
357
		return $this->renderSongs($playlistTracks, $auth);
358
	}
359
360
	protected function tags($filter, $exact, $limit, $offset) {
361
		$genres = $this->findEntities($this->genreBusinessLayer, $filter, $exact, $limit, $offset);
362
		return $this->renderTags($genres);
363
	}
364
365
	protected function tag($tagId) {
366
		$userId = $this->ampacheUser->getUserId();
367
		$genre = $this->genreBusinessLayer->find($tagId, $userId);
368
		return $this->renderTags([$genre]);
369
	}
370
371
	protected function tag_artists($genreId, $limit, $offset, $auth) {
372
		$userId = $this->ampacheUser->getUserId();
373
		$artists = $this->artistBusinessLayer->findAllByGenre($genreId, $userId, $limit, $offset);
374
		return $this->renderArtists($artists, $auth);
375
	}
376
377
	protected function tag_albums($genreId, $limit, $offset, $auth) {
378
		$userId = $this->ampacheUser->getUserId();
379
		$albums = $this->albumBusinessLayer->findAllByGenre($genreId, $userId, $limit, $offset);
380
		return $this->renderAlbums($albums, $auth);
381
	}
382
383
	protected function tag_songs($genreId, $limit, $offset, $auth) {
384
		$userId = $this->ampacheUser->getUserId();
385
		$tracks = $this->trackBusinessLayer->findAllByGenre($genreId, $userId, $limit, $offset);
386
		$this->injectArtistAndAlbum($tracks, $artist);
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $artist seems to be never defined.
Loading history...
387
		return $this->renderSongs($tracks, $auth);
388
	}
389
390
	protected function download($trackId) {
391
		$userId = $this->ampacheUser->getUserId();
392
393
		try {
394
			$track = $this->trackBusinessLayer->find($trackId, $userId);
395
		} catch (BusinessLayerException $e) {
396
			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...
397
		}
398
399
		$files = $this->rootFolder->getUserFolder($userId)->getById($track->getFileId());
400
401
		if (\count($files) === 1) {
402
			return new FileResponse($files[0]);
403
		} else {
404
			return new ErrorResponse(Http::STATUS_NOT_FOUND);
405
		}
406
	}
407
408
	protected function stream($trackId, $offset) {
409
		// This is just a dummy implementation. We don't support transcoding or streaming
410
		// from a time offset.
411
		// All the other unsupported arguments are just ignored, but a request with an offset
412
		// is responded with an error. This is becuase the client would probably work in an
413
		// unexpected way if it thinks it's streaming from offset but actually it is streaming
414
		// from the beginning of the file. Returning an error gives the clien a chance to fallback
415
		// to other methods of seeking.
416
		if ($offset !== null) {
417
			throw new AmpacheException('Streaming with time offset is not supported', 405);
418
		}
419
420
		return $this->download($trackId);
421
	}
422
423
	/***************************************************************
424
	 * API methods which are not part of the Ampache specification *
425
	 ***************************************************************/
426
	protected function _get_album_cover($albumId) {
427
		return $this->getCover($albumId, $this->albumBusinessLayer);
428
	}
429
430
	protected function _get_artist_cover($artistId) {
431
		return $this->getCover($artistId, $this->artistBusinessLayer);
432
	}
433
434
435
	/********************
436
	 * Helper functions *
437
	 ********************/
438
439
	private function getCover($entityId, BusinessLayer $businessLayer) {
440
		$userId = $this->ampacheUser->getUserId();
441
		$userFolder = $this->rootFolder->getUserFolder($userId);
442
		$entity = $businessLayer->find($entityId, $userId);
443
444
		try {
445
			$coverData = $this->coverHelper->getCover($entity, $userId, $userFolder);
446
			if ($coverData !== null) {
447
				return new FileResponse($coverData);
448
			}
449
		} catch (BusinessLayerException $e) {
450
			return new ErrorResponse(Http::STATUS_NOT_FOUND, 'entity not found');
451
		}
452
453
		return new ErrorResponse(Http::STATUS_NOT_FOUND, 'entity has no cover');
454
	}
455
456
	private function checkHandshakeTimestamp($timestamp, $currentTime) {
457
		$providedTime = \intval($timestamp);
458
459
		if ($providedTime === 0) {
460
			throw new AmpacheException('Invalid Login - cannot parse time', 401);
461
		}
462
		if ($providedTime < ($currentTime - self::SESSION_EXPIRY_TIME)) {
463
			throw new AmpacheException('Invalid Login - session is outdated', 401);
464
		}
465
		// Allow the timestamp to be at maximum 10 minutes in the future. The client may use its
466
		// own system clock to generate the timestamp and that may differ from the server's time.
467
		if ($providedTime > $currentTime + 600) {
468
			throw new AmpacheException('Invalid Login - timestamp is in future', 401);
469
		}
470
	}
471
472
	private function checkHandshakeAuthentication($user, $timestamp, $auth) {
473
		$hashes = $this->ampacheUserMapper->getPasswordHashes($user);
474
475
		foreach ($hashes as $hash) {
476
			$expectedHash = \hash('sha256', $timestamp . $hash);
477
478
			if ($expectedHash === $auth) {
479
				return;
480
			}
481
		}
482
483
		throw new AmpacheException('Invalid Login - passphrase does not match', 401);
484
	}
485
486
	private function startNewSession($user, $expiryDate) {
487
		// this can cause collision, but it's just a temporary token
488
		$token = \md5(\uniqid(\rand(), true));
489
490
		// create new session
491
		$session = new AmpacheSession();
492
		$session->setUserId($user);
493
		$session->setToken($token);
494
		$session->setExpiry($expiryDate);
495
496
		// save session
497
		$this->ampacheSessionMapper->insert($session);
498
499
		return $token;
500
	}
501
502
	private function findEntities(BusinessLayer $businessLayer, $filter, $exact, $limit=null, $offset=null) {
503
		$userId = $this->ampacheUser->getUserId();
504
505
		if ($filter) {
506
			$fuzzy = !((boolean) $exact);
507
			return $businessLayer->findAllByName($filter, $userId, $fuzzy, $limit, $offset);
508
		} else {
509
			return $businessLayer->findAll($userId, SortBy::Name, $limit, $offset);
510
		}
511
	}
512
513
	/**
514
	 * Getting all tracks with this helper is more efficient than with `findEntities`
515
	 * followed by `injectArtistAndAlbum`. This is because, under the hood, the albums
516
	 * and artists are fetched with a single DB query instead of fetching each separately.
517
	 * 
518
	 * The result set is ordered first by artist and then by song title.
519
	 */
520
	private function getAllTracks() {
521
		$userId = $this->ampacheUser->getUserId();
522
		$tracks = $this->library->getTracksAlbumsAndArtists($userId)['tracks'];
523
		\usort($tracks, ['\OCA\Music\Db\Track', 'compareArtistAndTitle']);
524
		foreach ($tracks as $index => &$track) {
525
			$track->setNumberOnPlaylist($index + 1);
526
		}
527
		return $tracks;
528
	}
529
530
	private function createAmpacheActionUrl($action, $id, $auth) {
531
		$api = $this->jsonMode ? 'music.ampache.jsonApi' : 'music.ampache.xmlApi';
532
		return $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute($api))
533
				. "?action=$action&id=$id&auth=$auth";
534
	}
535
536
	private function createCoverUrl($entity, $auth) {
537
		if ($entity instanceof Album) {
538
			$type = 'album';
539
		} elseif ($entity instanceof Artist) {
540
			$type = 'artist';
541
		} else {
542
			throw new AmpacheException('unexpeted entity type for cover image', 500);
543
		}
544
545
		if ($entity->getCoverFileId()) {
546
			return $this->createAmpacheActionUrl("_get_{$type}_cover", $entity->getId(), $auth);
547
		} else {
548
			return '';
549
		}
550
	}
551
552
	/**
553
	 * Any non-integer values and integer value 0 are converted to null to
554
	 * indicate "no limit" or "no offset".
555
	 * @param string $value
556
	 * @return integer|null
557
	 */
558
	private static function validateLimitOrOffset($value) {
559
		if (\ctype_digit(\strval($value)) && $value !== 0) {
560
			return \intval($value);
561
		} else {
562
			return null;
563
		}
564
	}
565
566
	/**
567
	 * @param int $index
568
	 * @param int|null $offset
569
	 * @param int|null $limit
570
	 * @return boolean
571
	 */
572
	private static function indexIsWithinOffsetAndLimit($index, $offset, $limit) {
573
		$offset = \intval($offset); // missing offset is interpreted as 0-offset
574
		return ($limit === null) || ($index >= $offset && $index < $offset + $limit);
575
	}
576
577
	private function renderArtists($artists, $auth) {
578
		$userId = $this->ampacheUser->getUserId();
579
		$genreMap = Util::createIdLookupTable($this->genreBusinessLayer->findAll($userId));
580
581
		return $this->ampacheResponse([
582
			'artist' => \array_map(function($artist) use ($userId, $genreMap, $auth) {
583
				return [
584
					'id' => (string)$artist->getId(),
585
					'name' => $artist->getNameString($this->l10n),
586
					'albums' => $this->albumBusinessLayer->countByArtist($artist->getId()),
587
					'songs' => $this->trackBusinessLayer->countByArtist($artist->getId()),
588
					'art' => $this->createCoverUrl($artist, $auth),
589
					'rating' => 0,
590
					'preciserating' => 0,
591
					'tag' => \array_map(function($genreId) use ($genreMap) {
592
						return [
593
							'id' => (string)$genreId,
594
							'value' => $genreMap[$genreId]->getNameString($this->l10n),
595
							'count' => 1
596
						];
597
					}, $this->trackBusinessLayer->getGenresByArtistId($artist->getId(), $userId))
598
				];
599
			}, $artists)
600
		]);
601
	}
602
603
	private function renderAlbums($albums, $auth) {
604
		$userId = $this->ampacheUser->getUserId();
605
606
		$genreMap = Util::createIdLookupTable($this->genreBusinessLayer->findAll($userId));
607
608
		return $this->ampacheResponse([
609
			'album' => \array_map(function($album) use ($userId, $auth, $genreMap) {
610
				$artist = $this->artistBusinessLayer->find($album->getAlbumArtistId(), $userId);
611
				return [
612
					'id' => (string)$album->getId(),
613
					'name' => $album->getNameString($this->l10n),
614
					'artist' => [
615
						'id' => (string)$artist->getId(),
616
						'value' => $artist->getNameString($this->l10n)
617
					],
618
					'tracks' => $this->trackBusinessLayer->countByAlbum($album->getId()),
619
					'rating' => 0,
620
					'year' => $album->yearToAPI(),
621
					'art' => $this->createCoverUrl($album, $auth),
622
					'preciserating' => 0,
623
					'tag' => \array_map(function($genreId) use ($genreMap) {
624
						return [
625
							'id' => (string)$genreId,
626
							'value' => $genreMap[$genreId]->getNameString($this->l10n),
627
							'count' => 1
628
						];
629
					}, $album->getGenres())
630
				];
631
			}, $albums)
632
		]);
633
	}
634
635
	private function injectArtistAndAlbum(&$tracks, $commonArtist=null, $commonAlbum=null) {
636
		$userId = $this->ampacheUser->getUserId();
637
638
		foreach ($tracks as &$track) {
639
			$artist = $commonArtist ?: $this->artistBusinessLayer->find($track->getArtistId(), $userId);
640
			$track->setArtist($artist);
641
642
			if (!empty($commonAlbum)) {
643
				$track->setAlbum($commonAlbum);
644
			} else {
645
				$album = $this->albumBusinessLayer->find($track->getAlbumId(), $userId);
646
				$album->setAlbumArtist($this->artistBusinessLayer->find($album->getAlbumArtistId(), $userId));
647
				$track->setAlbum($album);
648
			}
649
		}
650
	}
651
652
	private function renderSongs($tracks, $auth) {
653
		$userId = $this->ampacheUser->getUserId();
654
		$genreMap = Util::createIdLookupTable($this->genreBusinessLayer->findAll($userId));
655
656
		return $this->ampacheResponse([
657
			'song' => \array_map(function($track) use ($auth, $genreMap) {
658
				$artist = $track->getArtist();
659
				$album = $track->getAlbum();
660
				$albumArtist = $album->getAlbumArtist();
661
662
				$result = [
663
					'id' => (string)$track->getId(),
664
					'title' => $track->getTitle(),
665
					'artist' => [
666
						'id' => (string)$artist->getId(),
667
						'value' => $artist->getNameString($this->l10n)
668
					],
669
					'albumartist' => [
670
						'id' => (string)$albumArtist->getId(),
671
						'value' => $albumArtist->getNameString($this->l10n)
672
					],
673
					'album' => [
674
						'id' => (string)$album->getId(),
675
						'value' => $album->getNameString($this->l10n)
676
					],
677
					'url' => $this->createAmpacheActionUrl('download', $track->getId(), $auth),
678
					'time' => $track->getLength(),
679
					'year' => $track->getYear(),
680
					'track' => $track->getAdjustedTrackNumber(),
681
					'bitrate' => $track->getBitrate(),
682
					'mime' => $track->getMimetype(),
683
					'size' => $track->getSize(),
684
					'art' => $this->createCoverUrl($album, $auth),
685
					'rating' => 0,
686
					'preciserating' => 0,
687
				];
688
689
				$genreId = $track->getGenreId();
690
				if ($genreId !== null) {
691
					$result['tag'] = [[
692
						'id' => (string)$genreId,
693
						'value' => $genreMap[$genreId]->getNameString($this->l10n),
694
						'count' => 1
695
					]];
696
				}
697
				return $result;
698
			}, $tracks)
699
		]);
700
	}
701
702
	private function renderPlaylists($playlists) {
703
		return $this->ampacheResponse([
704
			'playlist' => \array_map(function($playlist) {
705
				return [
706
					'id' => (string)$playlist->getId(),
707
					'name' => $playlist->getName(),
708
					'owner' => $this->ampacheUser->getUserId(),
709
					'items' => $playlist->getTrackCount(),
710
					'type' => 'Private'
711
				];
712
			}, $playlists)
713
		]);
714
	}
715
716
	private function renderTags($genres) {
717
		return $this->ampacheResponse([
718
			'tag' => \array_map(function($genre) {
719
				return [
720
					'id' => (string)$genre->getId(),
721
					'name' => $genre->getNameString($this->l10n),
722
					'albums' => $genre->getAlbumCount(),
723
					'artists' => $genre->getArtistCount(),
724
					'songs' => $genre->getTrackCount(),
725
					'videos' => 0,
726
					'playlists' => 0,
727
					'stream' => 0
728
				];
729
			}, $genres)
730
		]);
731
	}
732
733
	/**
734
	 * Array is considered to be "indexed" if its first element has numerical key.
735
	 * Empty array is considered to be "indexed".
736
	 * @param array $array
737
	 */
738
	private static function arrayIsIndexed(array $array) {
739
		reset($array);
740
		return empty($array) || \is_int(key($array));
741
	}
742
743
	/**
744
	 * The JSON API has some asymmetries with the JSON API. This function makes the needed
745
	 * translations for the result content before it is converted into JSON. 
746
	 * @param array $content
747
	 * @return array
748
	 */
749
	private static function prepareResultForJsonApi($content) {
750
		// In all responses returning an array of library entities, the root node is anonymous.
751
		// Unwrap the outermost array if it is an associative array with a single array-type value.
752
		if (\count($content) === 1 && !self::arrayIsIndexed($content)
753
				&& \is_array(\current($content)) && self::arrayIsIndexed(\current($content))) {
754
			$content = \array_pop($content);
755
		}
756
757
		// The key 'value' has a special meaning on XML responses, as it makes the corresponding value
758
		// to be treated as text content of the parent element. In the JSON API, these are mostly
759
		// substituted with property 'name', but error responses use the property 'message', instead.
760
		if (\array_key_exists('error', $content)) {
761
			$content = Util::convertArrayKeys($content, ['value' => 'message']);
762
		} else {
763
			$content = Util::convertArrayKeys($content, ['value' => 'name']);
764
		}
765
		return $content;
766
	}
767
}
768
769
/**
770
 * Adapter class which acts like the Playlist class for the purpose of 
771
 * AmpacheController::renderPlaylists but contains all the track of the user. 
772
 */
773
class AmpacheController_AllTracksPlaylist {
774
775
	private $user;
776
	private $trackBusinessLayer;
777
	private $l10n;
778
779
	public function __construct($user, $trackBusinessLayer, $l10n) {
780
		$this->user = $user;
781
		$this->trackBusinessLayer = $trackBusinessLayer;
782
		$this->l10n = $l10n;
783
	}
784
785
	public function getId() {
786
		return AmpacheController::ALL_TRACKS_PLAYLIST_ID;
787
	}
788
789
	public function getName() {
790
		return $this->l10n->t('All tracks');
791
	}
792
793
	public function getTrackCount() {
794
		return $this->trackBusinessLayer->count($this->user);
795
	}
796
}
797