Passed
Push — master ( 28aace...5efe1b )
by Pauli
04:20
created

AmpacheController::renderArtists()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 23
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 1
eloc 19
nc 1
nop 2
dl 0
loc 23
ccs 0
cts 16
cp 0
crap 2
rs 9.6333
c 3
b 0
f 0
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\Random;
48
use \OCA\Music\Utility\Util;
49
50
class AmpacheController extends Controller {
51
	private $ampacheUserMapper;
52
	private $ampacheSessionMapper;
53
	private $albumBusinessLayer;
54
	private $artistBusinessLayer;
55
	private $genreBusinessLayer;
56
	private $playlistBusinessLayer;
57
	private $trackBusinessLayer;
58
	private $library;
59
	private $ampacheUser;
60
	private $urlGenerator;
61
	private $rootFolder;
62
	private $l10n;
63
	private $coverHelper;
64
	private $random;
65
	private $logger;
66
	private $jsonMode;
67
68
	const SESSION_EXPIRY_TIME = 6000;
69
	const ALL_TRACKS_PLAYLIST_ID = 10000000;
70
	const API_VERSION = 400001;
71
	const API_MIN_COMPATIBLE_VERSION = 350001;
72
73
	public function __construct($appname,
74
								IRequest $request,
75
								$l10n,
76
								IURLGenerator $urlGenerator,
77
								AmpacheUserMapper $ampacheUserMapper,
78
								AmpacheSessionMapper $ampacheSessionMapper,
79
								AlbumBusinessLayer $albumBusinessLayer,
80
								ArtistBusinessLayer $artistBusinessLayer,
81
								GenreBusinessLayer $genreBusinessLayer,
82
								PlaylistBusinessLayer $playlistBusinessLayer,
83
								TrackBusinessLayer $trackBusinessLayer,
84
								Library $library,
85
								AmpacheUser $ampacheUser,
86
								$rootFolder,
87
								CoverHelper $coverHelper,
88
								Random $random,
89
								Logger $logger) {
90
		parent::__construct($appname, $request);
91
92
		$this->ampacheUserMapper = $ampacheUserMapper;
93
		$this->ampacheSessionMapper = $ampacheSessionMapper;
94
		$this->albumBusinessLayer = $albumBusinessLayer;
95
		$this->artistBusinessLayer = $artistBusinessLayer;
96
		$this->genreBusinessLayer = $genreBusinessLayer;
97
		$this->playlistBusinessLayer = $playlistBusinessLayer;
98
		$this->trackBusinessLayer = $trackBusinessLayer;
99
		$this->library = $library;
100
		$this->urlGenerator = $urlGenerator;
101
		$this->l10n = $l10n;
102
103
		// used to share user info with middleware
104
		$this->ampacheUser = $ampacheUser;
105
106
		// used to deliver actual media file
107
		$this->rootFolder = $rootFolder;
108
109
		$this->coverHelper = $coverHelper;
110
		$this->random = $random;
111
		$this->logger = $logger;
112
	}
113
114
	public function setJsonMode($useJsonMode) {
115
		$this->jsonMode = $useJsonMode;
116
	}
117
118
	public function ampacheResponse($content) {
119
		if ($this->jsonMode) {
120
			return new JSONResponse(self::prepareResultForJsonApi($content));
121
		} else {
122
			return new XMLResponse(self::prepareResultForXmlApi($content), ['id', 'count', 'code']);
123
		}
124
	}
125
126
	/**
127
	 * @NoAdminRequired
128
	 * @PublicPage
129
	 * @NoCSRFRequired
130
	 */
131
	public function xmlApi($action, $user, $timestamp, $auth, $filter, $exact, $limit, $offset, $id) {
132
		// differentation between xmlApi and jsonApi is made already by the middleware
133
		return $this->dispatch($action, $user, $timestamp, $auth, $filter, $exact, $limit, $offset, $id);
134
	}
135
136
	/**
137
	 * @NoAdminRequired
138
	 * @PublicPage
139
	 * @NoCSRFRequired
140
	 */
141
	public function jsonApi($action, $user, $timestamp, $auth, $filter, $exact, $limit, $offset, $id) {
142
		// differentation between xmlApi and jsonApi is made already by the middleware
143
		return $this->dispatch($action, $user, $timestamp, $auth, $filter, $exact, $limit, $offset, $id);
144
	}
145
146
	protected function dispatch($action, $user, $timestamp, $auth, $filter, $exact, $limit, $offset, $id) {
147
		$this->logger->log("Ampache action '$action' requested", 'debug');
148
149
		$limit = self::validateLimitOrOffset($limit);
150
		$offset = self::validateLimitOrOffset($offset);
151
152
		switch ($action) {
153
			case 'handshake':
154
				return $this->handshake($user, $timestamp, $auth);
155
			case 'ping':
156
				return $this->ping($auth);
157
			case 'get_indexes':
158
				return $this->get_indexes($filter, $limit, $offset);
159
			case 'artists':
160
				return $this->artists($filter, $exact, $limit, $offset, $auth);
161
			case 'artist':
162
				return $this->artist($filter, $auth);
163
			case 'artist_albums':
164
				return $this->artist_albums($filter, $auth);
165
			case 'album_songs':
166
				return $this->album_songs($filter, $auth);
167
			case 'albums':
168
				return $this->albums($filter, $exact, $limit, $offset, $auth);
169
			case 'album':
170
				return $this->album($filter, $auth);
171
			case 'artist_songs':
172
				return $this->artist_songs($filter, $auth);
173
			case 'songs':
174
				return $this->songs($filter, $exact, $limit, $offset, $auth);
175
			case 'song':
176
				return $this->song($filter, $auth);
177
			case 'search_songs':
178
				return $this->search_songs($filter, $auth);
179
			case 'playlists':
180
				return $this->playlists($filter, $exact, $limit, $offset);
181
			case 'playlist':
182
				return $this->playlist($filter);
183
			case 'playlist_songs':
184
				return $this->playlist_songs($filter, $limit, $offset, $auth);
185
			case 'playlist_generate':
186
				return $this->playlist_generate($filter, $limit, $offset, $auth);
187
			case 'tags':
188
				return $this->tags($filter, $exact, $limit, $offset);
189
			case 'tag':
190
				return $this->tag($filter);
191
			case 'tag_artists':
192
				return $this->tag_artists($filter, $limit, $offset, $auth);
193
			case 'tag_albums':
194
				return $this->tag_albums($filter, $limit, $offset, $auth);
195
			case 'tag_songs':
196
				return $this->tag_songs($filter, $limit, $offset, $auth);
197
			case 'download':
198
				return $this->download($id); // args 'type' and 'format' not supported
199
			case 'stream':
200
				return $this->stream($id, $offset); // args 'type', 'bitrate', 'format', and 'length' not supported
201
202
			# non Ampache API actions
203
			case '_get_album_cover':
204
				return $this->_get_album_cover($id);
205
			case '_get_artist_cover':
206
				return $this->_get_artist_cover($id);
207
		}
208
209
		$this->logger->log("Unsupported Ampache action '$action' requested", 'warn');
210
		throw new AmpacheException('Action not supported', 405);
211
	}
212
213
	/***********************
214
	 * Ampahce API methods *
215
	 ***********************/
216
217
	protected function handshake($user, $timestamp, $auth) {
218
		$currentTime = \time();
219
		$expiryDate = $currentTime + self::SESSION_EXPIRY_TIME;
220
221
		$this->checkHandshakeTimestamp($timestamp, $currentTime);
222
		$this->checkHandshakeAuthentication($user, $timestamp, $auth);
223
		$token = $this->startNewSession($user, $expiryDate);
224
225
		$currentTimeFormated = \date('c', $currentTime);
226
		$expiryDateFormated = \date('c', $expiryDate);
227
228
		return $this->ampacheResponse([
229
			'auth' => $token,
230
			'api' => self::API_VERSION,
231
			'update' => $currentTimeFormated,
232
			'add' => $currentTimeFormated,
233
			'clean' => $currentTimeFormated,
234
			'songs' => $this->trackBusinessLayer->count($user),
235
			'artists' => $this->artistBusinessLayer->count($user),
236
			'albums' => $this->albumBusinessLayer->count($user),
237
			'playlists' => $this->playlistBusinessLayer->count($user) + 1, // +1 for "All tracks"
238
			'session_expire' => $expiryDateFormated,
239
			'tags' => $this->genreBusinessLayer->count($user),
240
			'videos' => 0,
241
			'catalogs' => 0
242
		]);
243
	}
244
245
	protected function ping($auth) {
246
		$response = [
247
			// TODO: 'server' => Music app version,
248
			'version' => self::API_VERSION,
249
			'compatible' => self::API_MIN_COMPATIBLE_VERSION
250
		];
251
252
		if (!empty($auth)) {
253
			$response['session_expire'] = \date('c', $this->ampacheSessionMapper->getExpiryTime($auth));
0 ignored issues
show
Bug introduced by
It seems like $this->ampacheSessionMapper->getExpiryTime($auth) can also be of type false; however, parameter $timestamp of date() 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

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