Passed
Push — master ( 5efe1b...859657 )
by Pauli
03:01
created

AmpacheController::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 39
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
eloc 16
nc 1
nop 17
dl 0
loc 39
ccs 0
cts 17
cp 0
crap 2
rs 9.7333
c 0
b 0
f 0

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\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', 'index', '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 'stats':
160
				return $this->stats($limit, $offset, $auth);
161
			case 'artists':
162
				return $this->artists($filter, $exact, $limit, $offset, $auth);
163
			case 'artist':
164
				return $this->artist($filter, $auth);
165
			case 'artist_albums':
166
				return $this->artist_albums($filter, $auth);
167
			case 'album_songs':
168
				return $this->album_songs($filter, $auth);
169
			case 'albums':
170
				return $this->albums($filter, $exact, $limit, $offset, $auth);
171
			case 'album':
172
				return $this->album($filter, $auth);
173
			case 'artist_songs':
174
				return $this->artist_songs($filter, $auth);
175
			case 'songs':
176
				return $this->songs($filter, $exact, $limit, $offset, $auth);
177
			case 'song':
178
				return $this->song($filter, $auth);
179
			case 'search_songs':
180
				return $this->search_songs($filter, $auth);
181
			case 'playlists':
182
				return $this->playlists($filter, $exact, $limit, $offset);
183
			case 'playlist':
184
				return $this->playlist($filter);
185
			case 'playlist_songs':
186
				return $this->playlist_songs($filter, $limit, $offset, $auth);
187
			case 'playlist_generate':
188
				return $this->playlist_generate($filter, $limit, $offset, $auth);
189
			case 'tags':
190
				return $this->tags($filter, $exact, $limit, $offset);
191
			case 'tag':
192
				return $this->tag($filter);
193
			case 'tag_artists':
194
				return $this->tag_artists($filter, $limit, $offset, $auth);
195
			case 'tag_albums':
196
				return $this->tag_albums($filter, $limit, $offset, $auth);
197
			case 'tag_songs':
198
				return $this->tag_songs($filter, $limit, $offset, $auth);
199
			case 'download':
200
				return $this->download($id); // args 'type' and 'format' not supported
201
			case 'stream':
202
				return $this->stream($id, $offset); // args 'type', 'bitrate', 'format', and 'length' not supported
203
204
			# non Ampache API actions
205
			case '_get_album_cover':
206
				return $this->_get_album_cover($id);
207
			case '_get_artist_cover':
208
				return $this->_get_artist_cover($id);
209
		}
210
211
		$this->logger->log("Unsupported Ampache action '$action' requested", 'warn');
212
		throw new AmpacheException('Action not supported', 405);
213
	}
214
215
	/***********************
216
	 * Ampahce API methods *
217
	 ***********************/
218
219
	protected function handshake($user, $timestamp, $auth) {
220
		$currentTime = \time();
221
		$expiryDate = $currentTime + self::SESSION_EXPIRY_TIME;
222
223
		$this->checkHandshakeTimestamp($timestamp, $currentTime);
224
		$this->checkHandshakeAuthentication($user, $timestamp, $auth);
225
		$token = $this->startNewSession($user, $expiryDate);
226
227
		$currentTimeFormated = \date('c', $currentTime);
228
		$expiryDateFormated = \date('c', $expiryDate);
229
230
		return $this->ampacheResponse([
231
			'auth' => $token,
232
			'api' => self::API_VERSION,
233
			'update' => $currentTimeFormated,
234
			'add' => $currentTimeFormated,
235
			'clean' => $currentTimeFormated,
236
			'songs' => $this->trackBusinessLayer->count($user),
237
			'artists' => $this->artistBusinessLayer->count($user),
238
			'albums' => $this->albumBusinessLayer->count($user),
239
			'playlists' => $this->playlistBusinessLayer->count($user) + 1, // +1 for "All tracks"
240
			'session_expire' => $expiryDateFormated,
241
			'tags' => $this->genreBusinessLayer->count($user),
242
			'videos' => 0,
243
			'catalogs' => 0
244
		]);
245
	}
246
247
	protected function ping($auth) {
248
		$response = [
249
			// TODO: 'server' => Music app version,
250
			'version' => self::API_VERSION,
251
			'compatible' => self::API_MIN_COMPATIBLE_VERSION
252
		];
253
254
		if (!empty($auth)) {
255
			$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

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