Passed
Push — master ( 64b798...0837cd )
by Pauli
01:55
created

AmpacheController::ping()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 12
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 6

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 6
nc 2
nop 1
dl 0
loc 12
ccs 0
cts 6
cp 0
crap 6
rs 10
c 1
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', '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 'flag':
200
				return $this->flag();
201
			case 'download':
202
				return $this->download($id); // args 'type' and 'format' not supported
203
			case 'stream':
204
				return $this->stream($id, $offset); // args 'type', 'bitrate', 'format', and 'length' not supported
205
206
			# non Ampache API actions
207
			case '_get_album_cover':
208
				return $this->_get_album_cover($id);
209
			case '_get_artist_cover':
210
				return $this->_get_artist_cover($id);
211
		}
212
213
		$this->logger->log("Unsupported Ampache action '$action' requested", 'warn');
214
		throw new AmpacheException('Action not supported', 405);
215
	}
216
217
	/***********************
218
	 * Ampahce API methods *
219
	 ***********************/
220
221
	protected function handshake($user, $timestamp, $auth) {
222
		$currentTime = \time();
223
		$expiryDate = $currentTime + self::SESSION_EXPIRY_TIME;
224
225
		$this->checkHandshakeTimestamp($timestamp, $currentTime);
226
		$this->checkHandshakeAuthentication($user, $timestamp, $auth);
227
		$token = $this->startNewSession($user, $expiryDate);
228
229
		$currentTimeFormated = \date('c', $currentTime);
230
		$expiryDateFormated = \date('c', $expiryDate);
231
232
		return $this->ampacheResponse([
233
			'auth' => $token,
234
			'api' => self::API_VERSION,
235
			'update' => $currentTimeFormated,
236
			'add' => $currentTimeFormated,
237
			'clean' => $currentTimeFormated,
238
			'songs' => $this->trackBusinessLayer->count($user),
239
			'artists' => $this->artistBusinessLayer->count($user),
240
			'albums' => $this->albumBusinessLayer->count($user),
241
			'playlists' => $this->playlistBusinessLayer->count($user) + 1, // +1 for "All tracks"
242
			'session_expire' => $expiryDateFormated,
243
			'tags' => $this->genreBusinessLayer->count($user),
244
			'videos' => 0,
245
			'catalogs' => 0
246
		]);
247
	}
248
249
	protected function ping($auth) {
250
		$response = [
251
			// TODO: 'server' => Music app version,
252
			'version' => self::API_VERSION,
253
			'compatible' => self::API_MIN_COMPATIBLE_VERSION
254
		];
255
256
		if (!empty($auth)) {
257
			$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

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

This check looks for imports that have been defined, but are not used in the scope.

Loading history...
912
				$userId = $this->ampacheUser->getUserId();
913
				$albums = $this->albumBusinessLayer->findAllByArtist($artist->getId(), $userId);
914
915
				return [
916
					'id' => (string)$artist->getId(),
917
					'name' => $artist->getNameString($this->l10n),
918
					'album' => \array_map(function($album) {
919
						return [
920
							'id' => (string)$album->getId(),
921
							'value' => $album->getNameString($this->l10n)
922
						];
923
					}, $albums)
924
				];
925
			}, $artists)
926
		]);
927
	}
928
929
	private function renderPlaylistsIndex($playlists) {
930
		return $this->ampacheResponse([
931
			'playlist' => \array_map(function($playlist) {
932
				return [
933
					'id' => (string)$playlist->getId(),
934
					'name' => $playlist->getName(),
935
					'playlisttrack' => $playlist->getTrackIdsAsArray()
936
				];
937
			}, $playlists)
938
		]);
939
	}
940
941
	private function renderEntityIds($entities) {
942
		return $this->ampacheResponse(['id' => Util::extractIds($entities)]);
943
	}
944
945
	/**
946
	 * Array is considered to be "indexed" if its first element has numerical key.
947
	 * Empty array is considered to be "indexed".
948
	 * @param array $array
949
	 */
950
	private static function arrayIsIndexed(array $array) {
951
		reset($array);
952
		return empty($array) || \is_int(key($array));
953
	}
954
955
	/**
956
	 * The JSON API has some asymmetries with the XML API. This function makes the needed
957
	 * translations for the result content before it is converted into JSON. 
958
	 * @param array $content
959
	 * @return array
960
	 */
961
	private static function prepareResultForJsonApi($content) {
962
		// In all responses returning an array of library entities, the root node is anonymous.
963
		// Unwrap the outermost array if it is an associative array with a single array-type value.
964
		if (\count($content) === 1 && !self::arrayIsIndexed($content)
965
				&& \is_array(\current($content)) && self::arrayIsIndexed(\current($content))) {
966
			$content = \array_pop($content);
967
		}
968
969
		// The key 'value' has a special meaning on XML responses, as it makes the corresponding value
970
		// to be treated as text content of the parent element. In the JSON API, these are mostly
971
		// substituted with property 'name', but error responses use the property 'message', instead.
972
		if (\array_key_exists('error', $content)) {
973
			$content = Util::convertArrayKeys($content, ['value' => 'message']);
974
		} else {
975
			$content = Util::convertArrayKeys($content, ['value' => 'name']);
976
		}
977
		return $content;
978
	}
979
980
	/**
981
	 * The XML API has some asymmetries with the JSON API. This function makes the needed
982
	 * translations for the result content before it is converted into XML. 
983
	 * @param array $content
984
	 * @return array
985
	 */
986
	private static function prepareResultForXmlApi($content) {
987
		\reset($content);
988
		$firstKey = \key($content);
989
990
		// all 'entity list' kind of responses shall have the (deprecated) total_count element
991
		if ($firstKey == 'song' || $firstKey == 'album' || $firstKey == 'artist'
992
				|| $firstKey == 'playlist' || $firstKey == 'tag') {
993
			$content = ['total_count' => \count($content[$firstKey])] + $content;
994
		}
995
996
		// for some bizarre reason, the 'id' arrays have 'index' attributes in the XML format
997
		if ($firstKey == 'id') {
998
			$content['id'] = \array_map(function($id, $index) {
999
				return ['index' => $index, 'value' => $id];
1000
			}, $content['id'], \array_keys($content['id']));
1001
		}
1002
1003
		return ['root' => $content];
1004
	}
1005
1006
	private function getRequiredParam($paramName) {
1007
		$param = $this->request->getParam($paramName);
1008
1009
		if ($param === null) {
1010
			throw new AmpacheException("Required parameter '$paramName' missing", 400);
1011
		}
1012
1013
		return $param;
1014
	}
1015
}
1016
1017
/**
1018
 * Adapter class which acts like the Playlist class for the purpose of 
1019
 * AmpacheController::renderPlaylists but contains all the track of the user. 
1020
 */
1021
class AmpacheController_AllTracksPlaylist {
1022
1023
	private $user;
1024
	private $trackBusinessLayer;
1025
	private $l10n;
1026
1027
	public function __construct($user, $trackBusinessLayer, $l10n) {
1028
		$this->user = $user;
1029
		$this->trackBusinessLayer = $trackBusinessLayer;
1030
		$this->l10n = $l10n;
1031
	}
1032
1033
	public function getId() {
1034
		return AmpacheController::ALL_TRACKS_PLAYLIST_ID;
1035
	}
1036
1037
	public function getName() {
1038
		return $this->l10n->t('All tracks');
1039
	}
1040
1041
	public function getTrackCount() {
1042
		return $this->trackBusinessLayer->count($this->user);
1043
	}
1044
}
1045