Passed
Pull Request — master (#875)
by Pauli
04:48 queued 02:12
created

AmpacheController::jsonApi()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 11
dl 0
loc 3
rs 10
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 declare(strict_types=1);
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 - 2021
13
 */
14
15
namespace OCA\Music\Controller;
16
17
use \OCP\AppFramework\Controller;
18
use \OCP\AppFramework\Http;
19
use \OCP\AppFramework\Http\JSONResponse;
20
use \OCP\AppFramework\Http\RedirectResponse;
21
use \OCP\IRequest;
22
use \OCP\IURLGenerator;
23
24
use \OCA\Music\AppFramework\BusinessLayer\BusinessLayer;
25
use \OCA\Music\AppFramework\BusinessLayer\BusinessLayerException;
26
use \OCA\Music\AppFramework\Core\Logger;
27
use \OCA\Music\Middleware\AmpacheException;
28
29
use \OCA\Music\BusinessLayer\AlbumBusinessLayer;
30
use \OCA\Music\BusinessLayer\ArtistBusinessLayer;
31
use \OCA\Music\BusinessLayer\GenreBusinessLayer;
32
use \OCA\Music\BusinessLayer\Library;
33
use \OCA\Music\BusinessLayer\PlaylistBusinessLayer;
34
use \OCA\Music\BusinessLayer\PodcastChannelBusinessLayer;
35
use \OCA\Music\BusinessLayer\PodcastEpisodeBusinessLayer;
36
use \OCA\Music\BusinessLayer\TrackBusinessLayer;
37
38
use \OCA\Music\Db\Album;
39
use \OCA\Music\Db\AmpacheUserMapper;
40
use \OCA\Music\Db\AmpacheSession;
41
use \OCA\Music\Db\AmpacheSessionMapper;
42
use \OCA\Music\Db\Artist;
43
use \OCA\Music\Db\SortBy;
44
use \OCA\Music\Db\Track;
45
46
use \OCA\Music\Http\ErrorResponse;
47
use \OCA\Music\Http\FileResponse;
48
use \OCA\Music\Http\FileStreamResponse;
49
use \OCA\Music\Http\XmlResponse;
50
51
use \OCA\Music\Utility\AmpacheUser;
52
use \OCA\Music\Utility\CoverHelper;
53
use \OCA\Music\Utility\PodcastService;
54
use \OCA\Music\Utility\Random;
55
use \OCA\Music\Utility\Util;
56
57
class AmpacheController extends Controller {
58
	private $ampacheUserMapper;
59
	private $ampacheSessionMapper;
60
	private $albumBusinessLayer;
61
	private $artistBusinessLayer;
62
	private $genreBusinessLayer;
63
	private $playlistBusinessLayer;
64
	private $podcastChannelBusinessLayer;
65
	private $podcastEpisodeBusinessLayer;
66
	private $trackBusinessLayer;
67
	private $library;
68
	private $podcastService;
69
	private $ampacheUser;
70
	private $urlGenerator;
71
	private $rootFolder;
72
	private $l10n;
73
	private $coverHelper;
74
	private $random;
75
	private $logger;
76
	private $jsonMode;
77
78
	const SESSION_EXPIRY_TIME = 6000;
79
	const ALL_TRACKS_PLAYLIST_ID = 10000000;
80
	const API_VERSION = 440000;
81
	const API_MIN_COMPATIBLE_VERSION = 350001;
82
83
	public function __construct(string $appname,
84
								IRequest $request,
85
								$l10n,
86
								IURLGenerator $urlGenerator,
87
								AmpacheUserMapper $ampacheUserMapper,
88
								AmpacheSessionMapper $ampacheSessionMapper,
89
								AlbumBusinessLayer $albumBusinessLayer,
90
								ArtistBusinessLayer $artistBusinessLayer,
91
								GenreBusinessLayer $genreBusinessLayer,
92
								PlaylistBusinessLayer $playlistBusinessLayer,
93
								PodcastChannelBusinessLayer $podcastChannelBusinessLayer,
94
								PodcastEpisodeBusinessLayer $podcastEpisodeBusinessLayer,
95
								TrackBusinessLayer $trackBusinessLayer,
96
								Library $library,
97
								PodcastService $podcastService,
98
								AmpacheUser $ampacheUser,
99
								$rootFolder,
100
								CoverHelper $coverHelper,
101
								Random $random,
102
								Logger $logger) {
103
		parent::__construct($appname, $request);
104
105
		$this->ampacheUserMapper = $ampacheUserMapper;
106
		$this->ampacheSessionMapper = $ampacheSessionMapper;
107
		$this->albumBusinessLayer = $albumBusinessLayer;
108
		$this->artistBusinessLayer = $artistBusinessLayer;
109
		$this->genreBusinessLayer = $genreBusinessLayer;
110
		$this->playlistBusinessLayer = $playlistBusinessLayer;
111
		$this->podcastChannelBusinessLayer = $podcastChannelBusinessLayer;
112
		$this->podcastEpisodeBusinessLayer = $podcastEpisodeBusinessLayer;
113
		$this->trackBusinessLayer = $trackBusinessLayer;
114
		$this->library = $library;
115
		$this->podcastService = $podcastService;
116
		$this->urlGenerator = $urlGenerator;
117
		$this->l10n = $l10n;
118
119
		// used to share user info with middleware
120
		$this->ampacheUser = $ampacheUser;
121
122
		// used to deliver actual media file
123
		$this->rootFolder = $rootFolder;
124
125
		$this->coverHelper = $coverHelper;
126
		$this->random = $random;
127
		$this->logger = $logger;
128
	}
129
130
	public function setJsonMode($useJsonMode) {
131
		$this->jsonMode = $useJsonMode;
132
	}
133
134
	public function ampacheResponse($content) {
135
		if ($this->jsonMode) {
136
			return new JSONResponse(self::prepareResultForJsonApi($content));
137
		} else {
138
			return new XmlResponse(self::prepareResultForXmlApi($content), ['id', 'index', 'count', 'code']);
139
		}
140
	}
141
142
	/**
143
	 * @NoAdminRequired
144
	 * @PublicPage
145
	 * @NoCSRFRequired
146
	 */
147
	public function xmlApi($action, $user, $timestamp, $auth, $filter, $exact, $limit, $offset, $id, $add, $update) {
148
		// differentation between xmlApi and jsonApi is made already by the middleware
149
		return $this->dispatch($action, $user, $timestamp, $auth, $filter, $exact, $limit, $offset, $id, $add, $update);
150
	}
151
152
	/**
153
	 * @NoAdminRequired
154
	 * @PublicPage
155
	 * @NoCSRFRequired
156
	 */
157
	public function jsonApi($action, $user, $timestamp, $auth, $filter, $exact, $limit, $offset, $id, $add, $update) {
158
		// differentation between xmlApi and jsonApi is made already by the middleware
159
		return $this->dispatch($action, $user, $timestamp, $auth, $filter, $exact, $limit, $offset, $id, $add, $update);
160
	}
161
162
	protected function dispatch($action, $user, $timestamp, $auth, $filter, $exact, $limit, $offset, $id, $add, $update) {
163
		$this->logger->log("Ampache action '$action' requested", 'debug');
164
165
		$limit = self::validateLimitOrOffset($limit);
166
		$offset = self::validateLimitOrOffset($offset);
167
168
		switch ($action) {
169
			case 'handshake':
170
				return $this->handshake($user, $timestamp, $auth);
171
			case 'goodbye':
172
				return $this->goodbye($auth);
173
			case 'ping':
174
				return $this->ping($auth);
175
			case 'get_indexes':
176
				return $this->get_indexes($filter, $limit, $offset, $add, $update);
177
			case 'stats':
178
				return $this->stats($filter, $limit, $offset, $auth);
179
			case 'artists':
180
				return $this->artists($filter, $exact, $limit, $offset, $add, $update, $auth);
181
			case 'artist':
182
				return $this->artist((int)$filter, $auth);
183
			case 'artist_albums':
184
				return $this->artist_albums((int)$filter, $auth);
185
			case 'album_songs':
186
				return $this->album_songs((int)$filter, $auth);
187
			case 'albums':
188
				return $this->albums($filter, $exact, $limit, $offset, $add, $update, $auth);
189
			case 'album':
190
				return $this->album((int)$filter, $auth);
191
			case 'artist_songs':
192
				return $this->artist_songs((int)$filter, $auth);
193
			case 'songs':
194
				return $this->songs($filter, $exact, $limit, $offset, $add, $update, $auth);
195
			case 'song':
196
				return $this->song((int)$filter, $auth);
197
			case 'search_songs':
198
				return $this->search_songs($filter, $auth);
199
			case 'playlists':
200
				return $this->playlists($filter, $exact, $limit, $offset, $add, $update);
201
			case 'playlist':
202
				return $this->playlist((int)$filter);
203
			case 'playlist_songs':
204
				return $this->playlist_songs((int)$filter, $limit, $offset, $auth);
205
			case 'playlist_create':
206
				return $this->playlist_create();
207
			case 'playlist_edit':
208
				return $this->playlist_edit((int)$filter);
209
			case 'playlist_delete':
210
				return $this->playlist_delete((int)$filter);
211
			case 'playlist_add_song':
212
				return $this->playlist_add_song((int)$filter);
213
			case 'playlist_remove_song':
214
				return $this->playlist_remove_song((int)$filter);
215
			case 'playlist_generate':
216
				return $this->playlist_generate($filter, $limit, $offset, $auth);
217
			case 'podcasts':
218
				return $this->podcasts($filter, $exact, $limit, $offset);
219
			case 'podcast':
220
				return $this->podcast((int)$filter);
221
			case 'podcast_create':
222
				return $this->podcast_create();
223
			case 'podcast_delete':
224
				return $this->podcast_delete((int)$filter);
225
			case 'podcast_episodes':
226
				return $this->podcast_episodes((int)$filter, $limit, $offset);
227
			case 'podcast_episode':
228
				return $this->podcast_episode((int)$filter);
229
			case 'update_podcast':
230
				return $this->update_podcast((int)$id);
231
			case 'tags':
232
				return $this->tags($filter, $exact, $limit, $offset);
233
			case 'tag':
234
				return $this->tag((int)$filter);
235
			case 'tag_artists':
236
				return $this->tag_artists((int)$filter, $limit, $offset, $auth);
237
			case 'tag_albums':
238
				return $this->tag_albums((int)$filter, $limit, $offset, $auth);
239
			case 'tag_songs':
240
				return $this->tag_songs((int)$filter, $limit, $offset, $auth);
241
			case 'flag':
242
				return $this->flag();
243
			case 'download':
244
				return $this->download((int)$id); // arg 'format' not supported
245
			case 'stream':
246
				return $this->stream((int)$id, $offset); // args 'bitrate', 'format', and 'length' not supported
247
			case 'get_art':
248
				return $this->get_art((int)$id);
249
		}
250
251
		$this->logger->log("Unsupported Ampache action '$action' requested", 'warn');
252
		throw new AmpacheException('Action not supported', 405);
253
	}
254
255
	/***********************
256
	 * Ampahce API methods *
257
	 ***********************/
258
259
	protected function handshake($user, $timestamp, $auth) {
260
		$currentTime = \time();
261
		$expiryDate = $currentTime + self::SESSION_EXPIRY_TIME;
262
263
		$this->checkHandshakeTimestamp($timestamp, $currentTime);
264
		$this->checkHandshakeAuthentication($user, $timestamp, $auth);
265
		$token = $this->startNewSession($user, $expiryDate);
266
267
		$updateTime = \max($this->library->latestUpdateTime($user), $this->playlistBusinessLayer->latestUpdateTime($user));
268
		$addTime = \max($this->library->latestInsertTime($user), $this->playlistBusinessLayer->latestInsertTime($user));
269
270
		return $this->ampacheResponse([
271
			'auth' => $token,
272
			'api' => self::API_VERSION,
273
			'update' => $updateTime->format('c'),
274
			'add' => $addTime->format('c'),
275
			'clean' => \date('c', $currentTime), // TODO: actual time of the latest item removal
276
			'songs' => $this->trackBusinessLayer->count($user),
277
			'artists' => $this->artistBusinessLayer->count($user),
278
			'albums' => $this->albumBusinessLayer->count($user),
279
			'playlists' => $this->playlistBusinessLayer->count($user) + 1, // +1 for "All tracks"
280
			'podcasts' => $this->podcastChannelBusinessLayer->count($user),
281
			'podcast_episodes' => $this->podcastEpisodeBusinessLayer->count($user),
282
			'session_expire' => \date('c', $expiryDate),
283
			'tags' => $this->genreBusinessLayer->count($user),
284
			'videos' => 0,
285
			'catalogs' => 0
286
		]);
287
	}
288
289
	protected function goodbye($auth) {
290
		// getting the session should not throw as the middleware has already checked that the token is valid
291
		$session = $this->ampacheSessionMapper->findByToken($auth);
292
		$this->ampacheSessionMapper->delete($session);
293
294
		return $this->ampacheResponse(['success' => "goodbye: $auth"]);
295
	}
296
297
	protected function ping($auth) {
298
		$response = [
299
			'server' => $this->getAppNameAndVersion(),
300
			'version' => self::API_VERSION,
301
			'compatible' => self::API_MIN_COMPATIBLE_VERSION
302
		];
303
304
		if (!empty($auth)) {
305
			// getting the session should not throw as the middleware has already checked that the token is valid
306
			$session = $this->ampacheSessionMapper->findByToken($auth);
307
			$response['session_expire'] = \date('c', $session->getExpiry());
308
		}
309
310
		return $this->ampacheResponse($response);
311
	}
312
313
	protected function get_indexes($filter, $limit, $offset, $add, $update) {
314
		$type = $this->getRequiredParam('type');
315
316
		$businessLayer = $this->getBusinessLayer($type);
317
		$entities = $this->findEntities($businessLayer, $filter, false, $limit, $offset, $add, $update);
318
		return $this->renderEntitiesIndex($entities, $type);
319
	}
320
321
	protected function stats($filter, $limit, $offset, $auth) {
322
		$type = $this->getRequiredParam('type');
323
		$userId = $this->ampacheUser->getUserId();
324
325
		// Support for API v3.x: Originally, there was no 'filter' argument and the 'type'
326
		// argument had that role. The action only supported albums in this old format.
327
		// The 'filter' argument was added and role of 'type' changed in API v4.0.
328
		if (empty($filter)) {
329
			$filter = $type;
330
			$type = 'album';
331
		}
332
333
		// Note: according to the API documentation, types 'podcast' and 'podcast_episode' should not
334
		// be supported. However, we can make this extension with no extra effort.
335
		if (!\in_array($type, ['song', 'album', 'artist', 'podcast', 'podcast_episode'])) {
336
			throw new AmpacheException("Unsupported type $type", 400);
337
		}
338
		$businessLayer = $this->getBusinessLayer($type);
339
340
		switch ($filter) {
341
			case 'newest':
342
				$entities = $businessLayer->findAll($userId, SortBy::Newest, $limit, $offset);
343
				break;
344
			case 'flagged':
345
				$entities = $businessLayer->findAllStarred($userId, $limit, $offset);
346
				break;
347
			case 'random':
348
				$entities = $businessLayer->findAll($userId, SortBy::None);
349
				$indices = $this->random->getIndices(\count($entities), $offset, $limit, $userId, 'ampache_stats_'.$type);
350
				$entities = Util::arrayMultiGet($entities, $indices);
351
				break;
352
			case 'highest':		//TODO
353
			case 'frequent':	//TODO
354
			case 'recent':		//TODO
355
			case 'forgotten':	//TODO
356
			default:
357
				throw new AmpacheException("Unsupported filter $filter", 400);
358
		}
359
360
		return $this->renderEntities($entities, $type, $auth);
361
	}
362
363
	protected function artists($filter, $exact, $limit, $offset, $add, $update, $auth) {
364
		$artists = $this->findEntities($this->artistBusinessLayer, $filter, $exact, $limit, $offset, $add, $update);
365
		return $this->renderArtists($artists, $auth);
366
	}
367
368
	protected function artist($artistId, $auth) {
369
		$userId = $this->ampacheUser->getUserId();
370
		$artist = $this->artistBusinessLayer->find($artistId, $userId);
371
		return $this->renderArtists([$artist], $auth);
372
	}
373
374
	protected function artist_albums($artistId, $auth) {
375
		$userId = $this->ampacheUser->getUserId();
376
		$albums = $this->albumBusinessLayer->findAllByArtist($artistId, $userId);
377
		return $this->renderAlbums($albums, $auth);
378
	}
379
380
	protected function artist_songs($artistId, $auth) {
381
		$userId = $this->ampacheUser->getUserId();
382
		$tracks = $this->trackBusinessLayer->findAllByArtist($artistId, $userId);
383
		return $this->renderSongs($tracks, $auth);
384
	}
385
386
	protected function album_songs($albumId, $auth) {
387
		$userId = $this->ampacheUser->getUserId();
388
389
		$album = $this->albumBusinessLayer->find($albumId, $userId);
390
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $userId);
391
392
		foreach ($tracks as &$track) {
393
			$track->setAlbum($album);
394
		}
395
396
		return $this->renderSongs($tracks, $auth);
397
	}
398
399
	protected function song($trackId, $auth) {
400
		$userId = $this->ampacheUser->getUserId();
401
		$track = $this->trackBusinessLayer->find($trackId, $userId);
402
		$trackInArray = [$track];
403
		return $this->renderSongs($trackInArray, $auth);
404
	}
405
406
	protected function songs($filter, $exact, $limit, $offset, $add, $update, $auth) {
407
408
		// optimized handling for fetching the whole library
409
		// note: the ordering of the songs differs between these two cases
410
		if (empty($filter) && !$limit && !$offset && empty($add) && empty($update)) {
411
			$tracks = $this->getAllTracks();
412
		}
413
		// general case
414
		else {
415
			$tracks = $this->findEntities($this->trackBusinessLayer, $filter, $exact, $limit, $offset, $add, $update);
416
		}
417
418
		return $this->renderSongs($tracks, $auth);
419
	}
420
421
	protected function search_songs($filter, $auth) {
422
		$userId = $this->ampacheUser->getUserId();
423
		$tracks = $this->trackBusinessLayer->findAllByNameRecursive($filter, $userId);
424
		return $this->renderSongs($tracks, $auth);
425
	}
426
427
	protected function albums($filter, $exact, $limit, $offset, $add, $update, $auth) {
428
		$albums = $this->findEntities($this->albumBusinessLayer, $filter, $exact, $limit, $offset, $add, $update);
429
		return $this->renderAlbums($albums, $auth);
430
	}
431
432
	protected function album($albumId, $auth) {
433
		$userId = $this->ampacheUser->getUserId();
434
		$album = $this->albumBusinessLayer->find($albumId, $userId);
435
		return $this->renderAlbums([$album], $auth);
436
	}
437
438
	protected function playlists($filter, $exact, $limit, $offset, $add, $update) {
439
		$userId = $this->ampacheUser->getUserId();
440
		$playlists = $this->findEntities($this->playlistBusinessLayer, $filter, $exact, $limit, $offset, $add, $update);
441
442
		// append "All tracks" if not searching by name, and it is not off-limit
443
		$allTracksIndex = $this->playlistBusinessLayer->count($userId);
444
		if (empty($filter) && empty($add) && empty($update)
445
				&& self::indexIsWithinOffsetAndLimit($allTracksIndex, $offset, $limit)) {
446
			$playlists[] = new AmpacheController_AllTracksPlaylist($userId, $this->trackBusinessLayer, $this->l10n);
447
		}
448
449
		return $this->renderPlaylists($playlists);
450
	}
451
452
	protected function playlist($listId) {
453
		$userId = $this->ampacheUser->getUserId();
454
		if ($listId == self::ALL_TRACKS_PLAYLIST_ID) {
455
			$playlist = new AmpacheController_AllTracksPlaylist($userId, $this->trackBusinessLayer, $this->l10n);
456
		} else {
457
			$playlist = $this->playlistBusinessLayer->find($listId, $userId);
458
		}
459
		return $this->renderPlaylists([$playlist]);
460
	}
461
462
	protected function playlist_songs($listId, $limit, $offset, $auth) {
463
		if ($listId == self::ALL_TRACKS_PLAYLIST_ID) {
464
			$playlistTracks = $this->getAllTracks();
465
			$playlistTracks = \array_slice($playlistTracks, $offset ?? 0, $limit);
466
		} else {
467
			$userId = $this->ampacheUser->getUserId();
468
			$playlistTracks = $this->playlistBusinessLayer->getPlaylistTracks($listId, $userId, $limit, $offset);
469
		}
470
		return $this->renderSongs($playlistTracks, $auth);
471
	}
472
473
	protected function playlist_create() {
474
		$name = $this->getRequiredParam('name');
475
		$playlist = $this->playlistBusinessLayer->create($name, $this->ampacheUser->getUserId());
476
		return $this->renderPlaylists([$playlist]);
477
	}
478
479
	protected function playlist_edit($listId) {
480
		$name = $this->request->getParam('name');
481
		$items = $this->request->getParam('items'); // track IDs
482
		$tracks = $this->request->getParam('tracks'); // 1-based indices of the tracks
483
484
		$edited = false;
485
		$userId = $this->ampacheUser->getUserId();
486
		$playlist = $this->playlistBusinessLayer->find($listId, $userId);
487
488
		if (!empty($name)) {
489
			$playlist->setName($name);
490
			$edited = true;
491
		}
492
493
		$newTrackIds = Util::explode(',', $items);
494
		$newTrackOrdinals = Util::explode(',', $tracks);
495
496
		if (\count($newTrackIds) != \count($newTrackOrdinals)) {
497
			throw new AmpacheException("Arguments 'items' and 'tracks' must contain equal amount of elements", 400);
498
		} elseif (\count($newTrackIds) > 0) {
499
			$trackIds = $playlist->getTrackIdsAsArray();
500
501
			for ($i = 0, $count = \count($newTrackIds); $i < $count; ++$i) {
502
				$trackId = $newTrackIds[$i];
503
				if (!$this->trackBusinessLayer->exists($trackId, $userId)) {
504
					throw new AmpacheException("Invalid song ID $trackId", 404);
505
				}
506
				$trackIds[$newTrackOrdinals[$i]-1] = $trackId;
507
			}
508
509
			$playlist->setTrackIdsFromArray($trackIds);
510
			$edited = true;
511
		}
512
513
		if ($edited) {
514
			$this->playlistBusinessLayer->update($playlist);
515
			return $this->ampacheResponse(['success' => 'playlist changes saved']);
516
		} else {
517
			throw new AmpacheException('Nothing was changed', 400);
518
		}
519
	}
520
521
	protected function playlist_delete($listId) {
522
		$this->playlistBusinessLayer->delete($listId, $this->ampacheUser->getUserId());
523
		return $this->ampacheResponse(['success' => 'playlist deleted']);
524
	}
525
526
	protected function playlist_add_song($listId) {
527
		$song = $this->getRequiredParam('song'); // track ID
528
		$check = $this->request->getParam('check', false);
529
530
		$userId = $this->ampacheUser->getUserId();
531
		if (!$this->trackBusinessLayer->exists($song, $userId)) {
532
			throw new AmpacheException("Invalid song ID $song", 404);
533
		}
534
535
		$playlist = $this->playlistBusinessLayer->find($listId, $userId);
536
		$trackIds = $playlist->getTrackIdsAsArray();
537
538
		if ($check && \in_array($song, $trackIds)) {
539
			throw new AmpacheException("Can't add a duplicate item when check is enabled", 400);
540
		}
541
542
		$trackIds[] = $song;
543
		$playlist->setTrackIdsFromArray($trackIds);
544
		$this->playlistBusinessLayer->update($playlist);
545
		return $this->ampacheResponse(['success' => 'song added to playlist']);
546
	}
547
548
	protected function playlist_remove_song($listId) {
549
		$song = $this->request->getParam('song'); // track ID
550
		$track = $this->request->getParam('track'); // 1-based index of the track
551
		$clear = $this->request->getParam('clear'); // added in API v420000 but we support this already now
552
553
		$playlist = $this->playlistBusinessLayer->find($listId, $this->ampacheUser->getUserId());
554
555
		if ((int)$clear === 1) {
556
			$trackIds = [];
557
			$message = 'all songs removed from playlist';
558
		} elseif ($song !== null) {
559
			$trackIds = $playlist->getTrackIdsAsArray();
560
			if (!\in_array($song, $trackIds)) {
561
				throw new AmpacheException("Song $song not found in playlist", 404);
562
			}
563
			$trackIds = Util::arrayDiff($trackIds, [$song]);
564
			$message = 'song removed from playlist';
565
		} elseif ($track !== null) {
566
			$trackIds = $playlist->getTrackIdsAsArray();
567
			if ($track < 1 || $track > \count($trackIds)) {
568
				throw new AmpacheException("Track ordinal $track is out of bounds", 404);
569
			}
570
			unset($trackIds[$track-1]);
571
			$message = 'song removed from playlist';
572
		} else {
573
			throw new AmpacheException("One of the arguments 'clear', 'song', 'track' is required", 400);
574
		}
575
576
		$playlist->setTrackIdsFromArray($trackIds);
577
		$this->playlistBusinessLayer->update($playlist);
578
		return $this->ampacheResponse(['success' => $message]);
579
	}
580
581
	protected function playlist_generate($filter, $limit, $offset, $auth) {
582
		$mode = $this->request->getParam('mode', 'random');
583
		$album = $this->request->getParam('album');
584
		$artist = $this->request->getParam('artist');
585
		$flag = $this->request->getParam('flag');
586
		$format = $this->request->getParam('format', 'song');
587
588
		$tracks = $this->findEntities($this->trackBusinessLayer, $filter, false); // $limit and $offset are applied later
589
590
		// filter the found tracks according to the additional requirements
591
		if ($album !== null) {
592
			$tracks = \array_filter($tracks, function ($track) use ($album) {
593
				return ($track->getAlbumId() == $album);
594
			});
595
		}
596
		if ($artist !== null) {
597
			$tracks = \array_filter($tracks, function ($track) use ($artist) {
598
				return ($track->getArtistId() == $artist);
599
			});
600
		}
601
		if ($flag == 1) {
602
			$tracks = \array_filter($tracks, function ($track) {
603
				return ($track->getStarred() !== null);
604
			});
605
		}
606
		// After filtering, there may be "holes" between the array indices. Reindex the array.
607
		$tracks = \array_values($tracks);
608
609
		// Arguments 'limit' and 'offset' are optional
610
		$limit = $limit ?? \count($tracks);
611
		$offset = $offset ?? 0;
612
613
		if ($mode == 'random') {
614
			$userId = $this->ampacheUser->getUserId();
615
			$indices = $this->random->getIndices(\count($tracks), $offset, $limit, $userId, 'ampache_playlist_generate');
616
			$tracks = Util::arrayMultiGet($tracks, $indices);
617
		} else { // 'recent', 'forgotten', 'unplayed'
618
			throw new AmpacheException("Mode '$mode' is not supported", 400);
619
		}
620
621
		switch ($format) {
622
			case 'song':
623
				return $this->renderSongs($tracks, $auth);
624
			case 'index':
625
				return $this->renderSongsIndex($tracks);
626
			case 'id':
627
				return $this->renderEntityIds($tracks);
628
			default:
629
				throw new AmpacheException("Format '$format' is not supported", 400);
630
		}
631
	}
632
633
	protected function podcasts($filter, $exact, $limit, $offset) {
634
		$channels = $this->findEntities($this->podcastChannelBusinessLayer, $filter, $exact, $limit, $offset);
635
636
		if ($this->request->getParam('include') === 'episodes') {
637
			$userId = $this->ampacheUser->getUserId();
638
			$actuallyLimited = ($limit !== null && $limit < $this->podcastChannelBusinessLayer->count($userId));
639
			$allChannelsIncluded = (!$filter && !$actuallyLimited && !$offset);
640
			$this->podcastService->injectEpisodes($channels, $userId, $allChannelsIncluded);
641
		}
642
643
		return $this->renderPodcastChannels($channels);
644
	}
645
646
	protected function podcast(int $channelId) {
647
		$userId = $this->ampacheUser->getUserId();
648
		$channel = $this->podcastChannelBusinessLayer->find($channelId, $userId);
649
650
		if ($this->request->getParam('include') === 'episodes') {
651
			$channel->setEpisodes($this->podcastEpisodeBusinessLayer->findAllByChannel($channelId, $userId));
652
		}
653
654
		return $this->renderPodcastChannels([$channel]);
655
	}
656
657
	protected function podcast_create() {
658
		$url = $this->getRequiredParam('url');
659
		$userId = $this->ampacheUser->getUserId();
660
		$result = $this->podcastService->subscribe($url, $userId);
661
662
		switch ($result['status']) {
663
			case PodcastService::STATUS_OK:
664
				return $this->renderPodcastChannels([$result['channel']]);
665
			case PodcastService::STATUS_INVALID_URL:
666
				throw new AmpacheException("Invalid URL $url", 400);
667
			case PodcastService::STATUS_INVALID_RSS:
668
				throw new AmpacheException("The document at URL $url is not a valid podcast RSS feed", 400);
669
			case PodcastService::STATUS_ALREADY_EXISTS:
670
				throw new AmpacheException('User already has this podcast channel subscribed', 400);
671
			default:
672
				throw new AmpacheException("Unexpected status code {$result['status']}", 400);
673
		}
674
	}
675
676
	protected function podcast_delete(int $channelId) {
677
		$userId = $this->ampacheUser->getUserId();
678
		$status = $this->podcastService->unsubscribe($channelId, $userId);
679
680
		switch ($status) {
681
			case PodcastService::STATUS_OK:
682
				return $this->ampacheResponse(['success' => 'podcast deleted']);
683
			case PodcastService::STATUS_NOT_FOUND:
684
				throw new AmpacheException('Channel to be deleted not found', 404);
685
			default:
686
				throw new AmpacheException("Unexpected status code $status", 400);
687
		}
688
	}
689
690
	protected function podcast_episodes(int $channelId, $limit, $offset) {
691
		$userId = $this->ampacheUser->getUserId();
692
		$episodes = $this->podcastEpisodeBusinessLayer->findAllByChannel($channelId, $userId, $limit, $offset);
693
		return $this->renderPodcastEpisodes($episodes);
694
	}
695
696
	protected function podcast_episode(int $episodeId) {
697
		$userId = $this->ampacheUser->getUserId();
698
		$episode = $this->podcastEpisodeBusinessLayer->find($episodeId, $userId);
699
		return $this->renderPodcastEpisodes([$episode]);
700
	}
701
702
	protected function update_podcast(int $channelId) {
703
		$userId = $this->ampacheUser->getUserId();
704
		$result = $this->podcastService->updateChannel($channelId, $userId);
705
706
		switch ($result['status']) {
707
			case PodcastService::STATUS_OK:
708
				$message = $result['updated'] ? 'channel was updated from the souce' : 'no changes found';
709
				return $this->ampacheResponse(['success' => $message]);
710
			case PodcastService::STATUS_NOT_FOUND:
711
				throw new AmpacheException('Channel to be updated not found', 404);
712
			case PodcastService::STATUS_INVALID_URL:
713
				throw new AmpacheException('failed to read from the channel URL', 400);
714
			case PodcastService::STATUS_INVALID_RSS:
715
				throw new AmpacheException('the document at the channel URL is not a valid podcast RSS feed', 400);
716
			default:
717
				throw new AmpacheException("Unexpected status code {$result['status']}", 400);
718
		}
719
	}
720
721
	protected function tags($filter, $exact, $limit, $offset) {
722
		$genres = $this->findEntities($this->genreBusinessLayer, $filter, $exact, $limit, $offset);
723
		return $this->renderTags($genres);
724
	}
725
726
	protected function tag($tagId) {
727
		$userId = $this->ampacheUser->getUserId();
728
		$genre = $this->genreBusinessLayer->find($tagId, $userId);
729
		return $this->renderTags([$genre]);
730
	}
731
732
	protected function tag_artists($genreId, $limit, $offset, $auth) {
733
		$userId = $this->ampacheUser->getUserId();
734
		$artists = $this->artistBusinessLayer->findAllByGenre($genreId, $userId, $limit, $offset);
735
		return $this->renderArtists($artists, $auth);
736
	}
737
738
	protected function tag_albums($genreId, $limit, $offset, $auth) {
739
		$userId = $this->ampacheUser->getUserId();
740
		$albums = $this->albumBusinessLayer->findAllByGenre($genreId, $userId, $limit, $offset);
741
		return $this->renderAlbums($albums, $auth);
742
	}
743
744
	protected function tag_songs($genreId, $limit, $offset, $auth) {
745
		$userId = $this->ampacheUser->getUserId();
746
		$tracks = $this->trackBusinessLayer->findAllByGenre($genreId, $userId, $limit, $offset);
747
		return $this->renderSongs($tracks, $auth);
748
	}
749
750
	protected function flag() {
751
		$type = $this->getRequiredParam('type');
752
		$id = $this->getRequiredParam('id');
753
		$flag = $this->getRequiredParam('flag');
754
		$flag = \filter_var($flag, FILTER_VALIDATE_BOOLEAN);
755
756
		if (!\in_array($type, ['song', 'album', 'artist', 'podcast', 'podcast_episode'])) {
757
			throw new AmpacheException("Unsupported type $type", 400);
758
		}
759
760
		$userId = $this->ampacheUser->getUserId();
761
		$businessLayer = $this->getBusinessLayer($type);
762
		if ($flag) {
763
			$modifiedCount = $businessLayer->setStarred([$id], $userId);
764
			$message = "flag ADDED to $type $id";
765
		} else {
766
			$modifiedCount = $businessLayer->unsetStarred([$id], $userId);
767
			$message = "flag REMOVED from $type $id";
768
		}
769
770
		if ($modifiedCount > 0) {
771
			return $this->ampacheResponse(['success' => $message]);
772
		} else {
773
			throw new AmpacheException("The $type $id was not found", 404);
774
		}
775
	}
776
777
	protected function download(int $trackId) {
778
		$type = $this->request->getParam('type', 'song');
779
		$userId = $this->ampacheUser->getUserId();
780
781
		if ($type === 'song') {
782
			try {
783
				$track = $this->trackBusinessLayer->find($trackId, $userId);
784
			} catch (BusinessLayerException $e) {
785
				return new ErrorResponse(Http::STATUS_NOT_FOUND, $e->getMessage());
786
			}
787
788
			$file = $this->rootFolder->getUserFolder($userId)->getById($track->getFileId())[0] ?? null;
789
790
			if ($file instanceof \OCP\Files\File) {
791
				return new FileStreamResponse($file);
792
			} else {
793
				return new ErrorResponse(Http::STATUS_NOT_FOUND);
794
			}
795
		} elseif ($type === 'podcast') {
796
			$episode = $this->podcastEpisodeBusinessLayer->find($trackId, $userId);
797
			return new RedirectResponse($episode->getStreamUrl());
798
		} else {
799
			throw new AmpacheException("Unsupported type '$type'", 400);
800
		}
801
	}
802
803
	protected function stream(int $trackId, $offset) {
804
		// This is just a dummy implementation. We don't support transcoding or streaming
805
		// from a time offset.
806
		// All the other unsupported arguments are just ignored, but a request with an offset
807
		// is responded with an error. This is becuase the client would probably work in an
808
		// unexpected way if it thinks it's streaming from offset but actually it is streaming
809
		// from the beginning of the file. Returning an error gives the client a chance to fallback
810
		// to other methods of seeking.
811
		if ($offset !== null) {
812
			throw new AmpacheException('Streaming with time offset is not supported', 400);
813
		}
814
815
		return $this->download($trackId);
816
	}
817
818
	protected function get_art(int $id) {
819
		$type = $this->getRequiredParam('type');
820
821
		if (!\in_array($type, ['song', 'album', 'artist'])) {
822
			throw new AmpacheException("Unsupported type $type", 400);
823
		}
824
825
		if ($type === 'song') {
826
			// map song to its parent album
827
			$id = $this->trackBusinessLayer->find($id, $this->ampacheUser->getUserId())->getAlbumId();
828
			$type = 'album';
829
		}
830
831
		return $this->getCover($id, $this->getBusinessLayer($type));
832
	}
833
834
	/********************
835
	 * Helper functions *
836
	 ********************/
837
838
	private function getBusinessLayer($type) {
839
		switch ($type) {
840
			case 'song':			return $this->trackBusinessLayer;
841
			case 'album':			return $this->albumBusinessLayer;
842
			case 'artist':			return $this->artistBusinessLayer;
843
			case 'playlist':		return $this->playlistBusinessLayer;
844
			case 'podcast':			return $this->podcastChannelBusinessLayer;
845
			case 'podcast_episode':	return $this->podcastEpisodeBusinessLayer;
846
			case 'tag':				return $this->genreBusinessLayer;
847
			default:				throw new AmpacheException("Unsupported type $type", 400);
848
		}
849
	}
850
851
	private function renderEntities($entities, $type, $auth) {
852
		switch ($type) {
853
			case 'song':			return $this->renderSongs($entities, $auth);
854
			case 'album':			return $this->renderAlbums($entities, $auth);
855
			case 'artist':			return $this->renderArtists($entities, $auth);
856
			case 'playlist':		return $this->renderPlaylists($entities);
857
			case 'podcast':			return $this->renderPodcastChannels($entities);
858
			case 'podcast_episode':	return $this->renderPodcastEpisodes($entities);
859
			case 'tag':				return $this->renderTags($entities);
860
			default:				throw new AmpacheException("Unsupported type $type", 400);
861
		}
862
	}
863
864
	private function renderEntitiesIndex($entities, $type) {
865
		switch ($type) {
866
			case 'song':			return $this->renderSongsIndex($entities);
867
			case 'album':			return $this->renderAlbumsIndex($entities);
868
			case 'artist':			return $this->renderArtistsIndex($entities);
869
			case 'playlist':		return $this->renderPlaylistsIndex($entities);
870
			case 'podcast':			return $this->renderPodcastChannelsIndex($entities);
871
			case 'podcast_episode':	return $this->renderPodcastEpisodesIndex($entities);
872
			default:				throw new AmpacheException("Unsupported type $type", 400);
873
		}
874
	}
875
876
	private function getAppNameAndVersion() {
877
		$vendor = 'owncloud/nextcloud'; // this should get overridden by the next 'include'
878
		include \OC::$SERVERROOT . '/version.php';
879
880
		// Note: the following is deprecated since NC14 but the replacement
881
		// \OCP\App\IAppManager::getAppVersion is not available before NC14.
882
		$appVersion = \OCP\App::getAppVersion($this->appName);
883
884
		return "$vendor {$this->appName} $appVersion";
885
	}
886
887
	private function getCover(int $entityId, BusinessLayer $businessLayer) {
888
		$userId = $this->ampacheUser->getUserId();
889
		$userFolder = $this->rootFolder->getUserFolder($userId);
890
891
		try {
892
			$entity = $businessLayer->find($entityId, $userId);
893
			$coverData = $this->coverHelper->getCover($entity, $userId, $userFolder);
894
			if ($coverData !== null) {
895
				return new FileResponse($coverData);
896
			}
897
		} catch (BusinessLayerException $e) {
898
			return new ErrorResponse(Http::STATUS_NOT_FOUND, 'entity not found');
899
		}
900
901
		return new ErrorResponse(Http::STATUS_NOT_FOUND, 'entity has no cover');
902
	}
903
904
	private function checkHandshakeTimestamp($timestamp, $currentTime) {
905
		$providedTime = \intval($timestamp);
906
907
		if ($providedTime === 0) {
908
			throw new AmpacheException('Invalid Login - cannot parse time', 401);
909
		}
910
		if ($providedTime < ($currentTime - self::SESSION_EXPIRY_TIME)) {
911
			throw new AmpacheException('Invalid Login - session is outdated', 401);
912
		}
913
		// Allow the timestamp to be at maximum 10 minutes in the future. The client may use its
914
		// own system clock to generate the timestamp and that may differ from the server's time.
915
		if ($providedTime > $currentTime + 600) {
916
			throw new AmpacheException('Invalid Login - timestamp is in future', 401);
917
		}
918
	}
919
920
	private function checkHandshakeAuthentication($user, $timestamp, $auth) {
921
		$hashes = $this->ampacheUserMapper->getPasswordHashes($user);
922
923
		foreach ($hashes as $hash) {
924
			$expectedHash = \hash('sha256', $timestamp . $hash);
925
926
			if ($expectedHash === $auth) {
927
				return;
928
			}
929
		}
930
931
		throw new AmpacheException('Invalid Login - passphrase does not match', 401);
932
	}
933
934
	private function startNewSession($user, $expiryDate) {
935
		$token = Random::secure(16);
936
937
		// create new session
938
		$session = new AmpacheSession();
939
		$session->setUserId($user);
940
		$session->setToken($token);
941
		$session->setExpiry($expiryDate);
942
943
		// save session
944
		$this->ampacheSessionMapper->insert($session);
945
946
		return $token;
947
	}
948
949
	private function findEntities(
950
			BusinessLayer $businessLayer, $filter, $exact, $limit=null, $offset=null, $add=null, $update=null) : array {
951
952
		$userId = $this->ampacheUser->getUserId();
953
954
		// It's not documented, but Ampache supports also specifying date range on `add` and `update` parameters
955
		// by using '/' as separator. If there is no such separator, then the value is used as a lower limit.
956
		$add = Util::explode('/', $add);
957
		$update = Util::explode('/', $update);
958
		$addMin = $add[0] ?? null;
959
		$addMax = $add[1] ?? null;
960
		$updateMin = $update[0] ?? null;
961
		$updateMax = $update[1] ?? null;
962
963
		if ($filter) {
964
			$fuzzy = !((boolean) $exact);
965
			return $businessLayer->findAllByName($filter, $userId, $fuzzy, $limit, $offset, $addMin, $addMax, $updateMin, $updateMax);
966
		} else {
967
			return $businessLayer->findAll($userId, SortBy::Name, $limit, $offset, $addMin, $addMax, $updateMin, $updateMax);
968
		}
969
	}
970
971
	/**
972
	 * Getting all tracks with this helper is more efficient than with `findEntities`
973
	 * followed by a call to `albumBusinessLayer->find(...)` on each track.
974
	 * This is because, under the hood, the albums are fetched with a single DB query
975
	 * instead of fetching each separately.
976
	 *
977
	 * The result set is ordered first by artist and then by song title.
978
	 */
979
	private function getAllTracks() {
980
		$userId = $this->ampacheUser->getUserId();
981
		$tracks = $this->library->getTracksAlbumsAndArtists($userId)['tracks'];
982
		\usort($tracks, ['\OCA\Music\Db\Track', 'compareArtistAndTitle']);
983
		foreach ($tracks as $index => &$track) {
984
			$track->setNumberOnPlaylist($index + 1);
985
		}
986
		return $tracks;
987
	}
988
989
	private function createAmpacheActionUrl($action, $id, $auth, $type=null) {
990
		$api = $this->jsonMode ? 'music.ampache.jsonApi' : 'music.ampache.xmlApi';
991
		return $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute($api))
992
				. "?action=$action&id=$id&auth=$auth"
993
				. (!empty($type) ? "&type=$type" : '');
994
	}
995
996
	private function createCoverUrl($entity, $auth) {
997
		if ($entity instanceof Album) {
998
			$type = 'album';
999
		} elseif ($entity instanceof Artist) {
1000
			$type = 'artist';
1001
		} else {
1002
			throw new AmpacheException('unexpeted entity type for cover image', 500);
1003
		}
1004
1005
		if ($entity->getCoverFileId()) {
1006
			return $this->createAmpacheActionUrl("get_art", $entity->getId(), $auth, $type);
1007
		} else {
1008
			return '';
1009
		}
1010
	}
1011
1012
	/**
1013
	 * Any non-integer values and integer value 0 are converted to null to
1014
	 * indicate "no limit" or "no offset".
1015
	 * @param string $value
1016
	 * @return integer|null
1017
	 */
1018
	private static function validateLimitOrOffset($value) : ?int {
1019
		$value = (int)$value;
1020
		return ($value > 0) ? $value : null;
1021
	}
1022
1023
	/**
1024
	 * @param int $index
1025
	 * @param int|null $offset
1026
	 * @param int|null $limit
1027
	 * @return boolean
1028
	 */
1029
	private static function indexIsWithinOffsetAndLimit($index, $offset, $limit) {
1030
		$offset = \intval($offset); // missing offset is interpreted as 0-offset
1031
		return ($limit === null) || ($index >= $offset && $index < $offset + $limit);
1032
	}
1033
1034
	private function renderArtists($artists, $auth) {
1035
		$userId = $this->ampacheUser->getUserId();
1036
		$genreMap = Util::createIdLookupTable($this->genreBusinessLayer->findAll($userId));
1037
1038
		return $this->ampacheResponse([
1039
			'artist' => \array_map(function (Artist $artist) use ($userId, $genreMap, $auth) {
1040
				return [
1041
					'id' => (string)$artist->getId(),
1042
					'name' => $artist->getNameString($this->l10n),
1043
					'albums' => $this->albumBusinessLayer->countByArtist($artist->getId()),
1044
					'songs' => $this->trackBusinessLayer->countByArtist($artist->getId()),
1045
					'art' => $this->createCoverUrl($artist, $auth),
1046
					'rating' => 0,
1047
					'preciserating' => 0,
1048
					'flag' => empty($artist->getStarred()) ? 0 : 1,
1049
					'tag' => \array_map(function ($genreId) use ($genreMap) {
1050
						return [
1051
							'id' => (string)$genreId,
1052
							'value' => $genreMap[$genreId]->getNameString($this->l10n),
1053
							'count' => 1
1054
						];
1055
					}, $this->trackBusinessLayer->getGenresByArtistId($artist->getId(), $userId))
1056
				];
1057
			}, $artists)
1058
		]);
1059
	}
1060
1061
	private function renderAlbums($albums, $auth) {
1062
		return $this->ampacheResponse([
1063
			'album' => \array_map(function (Album $album) use ($auth) {
1064
				return [
1065
					'id' => (string)$album->getId(),
1066
					'name' => $album->getNameString($this->l10n),
1067
					'artist' => [
1068
						'id' => (string)$album->getAlbumArtistId(),
1069
						'value' => $album->getAlbumArtistNameString($this->l10n)
1070
					],
1071
					'tracks' => $this->trackBusinessLayer->countByAlbum($album->getId()),
1072
					'rating' => 0,
1073
					'year' => $album->yearToAPI(),
1074
					'art' => $this->createCoverUrl($album, $auth),
1075
					'preciserating' => 0,
1076
					'flag' => empty($album->getStarred()) ? 0 : 1,
1077
					'tag' => \array_map(function ($genre) {
1078
						return [
1079
							'id' => (string)$genre->getId(),
1080
							'value' => $genre->getNameString($this->l10n),
1081
							'count' => 1
1082
						];
1083
					}, $album->getGenres() ?? [])
1084
				];
1085
			}, $albums)
1086
		]);
1087
	}
1088
1089
	private function renderSongs($tracks, $auth) {
1090
		return $this->ampacheResponse([
1091
			'song' => \array_map(function (Track $track) use ($auth) {
1092
				$userId = $this->ampacheUser->getUserId();
1093
				$album = $track->getAlbum()
1094
						?: $this->albumBusinessLayer->findOrDefault($track->getAlbumId(), $userId);
1095
1096
				$result = [
1097
					'id' => (string)$track->getId(),
1098
					'title' => $track->getTitle() ?: '',
1099
					'name' => $track->getTitle() ?: '',
1100
					'artist' => [
1101
						'id' => (string)$track->getArtistId() ?: '0',
1102
						'value' => $track->getArtistNameString($this->l10n)
1103
					],
1104
					'albumartist' => [
1105
						'id' => (string)$album->getAlbumArtistId() ?: '0',
1106
						'value' => $album->getAlbumArtistNameString($this->l10n)
1107
					],
1108
					'album' => [
1109
						'id' => (string)$album->getId() ?: '0',
1110
						'value' => $album->getNameString($this->l10n)
1111
					],
1112
					'url' => $this->createAmpacheActionUrl('download', $track->getId(), $auth),
1113
					'time' => $track->getLength(),
1114
					'year' => $track->getYear(),
1115
					'track' => $track->getAdjustedTrackNumber(),
1116
					'bitrate' => $track->getBitrate(),
1117
					'mime' => $track->getMimetype(),
1118
					'size' => $track->getSize(),
1119
					'art' => $this->createCoverUrl($album, $auth),
1120
					'rating' => 0,
1121
					'preciserating' => 0,
1122
					'flag' => empty($track->getStarred()) ? 0 : 1,
1123
				];
1124
1125
				$genreId = $track->getGenreId();
1126
				if ($genreId !== null) {
1127
					$result['tag'] = [[
1128
						'id' => (string)$genreId,
1129
						'value' => $track->getGenreNameString($this->l10n),
1130
						'count' => 1
1131
					]];
1132
				}
1133
				return $result;
1134
			}, $tracks)
1135
		]);
1136
	}
1137
1138
	private function renderPlaylists($playlists) {
1139
		return $this->ampacheResponse([
1140
			'playlist' => \array_map(function ($playlist) {
1141
				return [
1142
					'id' => (string)$playlist->getId(),
1143
					'name' => $playlist->getName(),
1144
					'owner' => $this->ampacheUser->getUserId(),
1145
					'items' => $playlist->getTrackCount(),
1146
					'type' => 'Private'
1147
				];
1148
			}, $playlists)
1149
		]);
1150
	}
1151
1152
	private function renderPodcastChannels(array $channels) {
1153
		return $this->ampacheResponse([
1154
			'podcast' => Util::arrayMapMethod($channels, 'toAmpacheApi')
1155
		]);
1156
	}
1157
1158
	private function renderPodcastEpisodes(array $episodes) {
1159
		return $this->ampacheResponse([
1160
			'podcast_episode' => Util::arrayMapMethod($episodes, 'toAmpacheApi')
1161
		]);
1162
	}
1163
1164
	private function renderTags($genres) {
1165
		return $this->ampacheResponse([
1166
			'tag' => \array_map(function ($genre) {
1167
				return [
1168
					'id' => (string)$genre->getId(),
1169
					'name' => $genre->getNameString($this->l10n),
1170
					'albums' => $genre->getAlbumCount(),
1171
					'artists' => $genre->getArtistCount(),
1172
					'songs' => $genre->getTrackCount(),
1173
					'videos' => 0,
1174
					'playlists' => 0,
1175
					'stream' => 0
1176
				];
1177
			}, $genres)
1178
		]);
1179
	}
1180
1181
	private function renderSongsIndex($tracks) {
1182
		return $this->ampacheResponse([
1183
			'song' => \array_map(function ($track) {
1184
				return [
1185
					'id' => (string)$track->getId(),
1186
					'title' => $track->getTitle(),
1187
					'name' => $track->getTitle(),
1188
					'artist' => [
1189
						'id' => (string)$track->getArtistId(),
1190
						'value' => $track->getArtistNameString($this->l10n)
1191
					],
1192
					'album' => [
1193
						'id' => (string)$track->getAlbumId(),
1194
						'value' => $track->getAlbumNameString($this->l10n)
1195
					]
1196
				];
1197
			}, $tracks)
1198
		]);
1199
	}
1200
1201
	private function renderAlbumsIndex($albums) {
1202
		return $this->ampacheResponse([
1203
			'album' => \array_map(function ($album) {
1204
				return [
1205
					'id' => (string)$album->getId(),
1206
					'name' => $album->getNameString($this->l10n),
1207
					'artist' => [
1208
						'id' => (string)$album->getAlbumArtistId(),
1209
						'value' => $album->getAlbumArtistNameString($this->l10n)
1210
					]
1211
				];
1212
			}, $albums)
1213
		]);
1214
	}
1215
1216
	private function renderArtistsIndex($artists) {
1217
		return $this->ampacheResponse([
1218
			'artist' => \array_map(function ($artist) {
1219
				$userId = $this->ampacheUser->getUserId();
1220
				$albums = $this->albumBusinessLayer->findAllByArtist($artist->getId(), $userId);
1221
1222
				return [
1223
					'id' => (string)$artist->getId(),
1224
					'name' => $artist->getNameString($this->l10n),
1225
					'album' => \array_map(function ($album) {
1226
						return [
1227
							'id' => (string)$album->getId(),
1228
							'value' => $album->getNameString($this->l10n)
1229
						];
1230
					}, $albums)
1231
				];
1232
			}, $artists)
1233
		]);
1234
	}
1235
1236
	private function renderPlaylistsIndex($playlists) {
1237
		return $this->ampacheResponse([
1238
			'playlist' => \array_map(function ($playlist) {
1239
				return [
1240
					'id' => (string)$playlist->getId(),
1241
					'name' => $playlist->getName(),
1242
					'playlisttrack' => $playlist->getTrackIdsAsArray()
1243
				];
1244
			}, $playlists)
1245
		]);
1246
	}
1247
1248
	private function renderPodcastChannelsIndex(array $channels) {
1249
		// The v4 API spec does not give any examples of this, and the v5 example is almost identical to the v4 "normal" result
1250
		return $this->renderPodcastChannels($channels);
1251
	}
1252
1253
	private function renderPodcastEpisodesIndex(array $episodes) {
1254
		// The v4 API spec does not give any examples of this, and the v5 example is almost identical to the v4 "normal" result
1255
		return $this->renderPodcastEpisodes($episodes);
1256
	}
1257
1258
	private function renderEntityIds($entities) {
1259
		return $this->ampacheResponse(['id' => Util::extractIds($entities)]);
1260
	}
1261
1262
	/**
1263
	 * Array is considered to be "indexed" if its first element has numerical key.
1264
	 * Empty array is considered to be "indexed".
1265
	 * @param array $array
1266
	 */
1267
	private static function arrayIsIndexed(array $array) {
1268
		\reset($array);
1269
		return empty($array) || \is_int(\key($array));
1270
	}
1271
1272
	/**
1273
	 * The JSON API has some asymmetries with the XML API. This function makes the needed
1274
	 * translations for the result content before it is converted into JSON.
1275
	 * @param array $content
1276
	 * @return array
1277
	 */
1278
	private static function prepareResultForJsonApi($content) {
1279
		// In all responses returning an array of library entities, the root node is anonymous.
1280
		// Unwrap the outermost array if it is an associative array with a single array-type value.
1281
		if (\count($content) === 1 && !self::arrayIsIndexed($content)
1282
				&& \is_array(\current($content)) && self::arrayIsIndexed(\current($content))) {
1283
			$content = \array_pop($content);
1284
		}
1285
1286
		// The key 'value' has a special meaning on XML responses, as it makes the corresponding value
1287
		// to be treated as text content of the parent element. In the JSON API, these are mostly
1288
		// substituted with property 'name', but error responses use the property 'message', instead.
1289
		if (\array_key_exists('error', $content)) {
1290
			$content = Util::convertArrayKeys($content, ['value' => 'message']);
1291
		} else {
1292
			$content = Util::convertArrayKeys($content, ['value' => 'name']);
1293
		}
1294
		return $content;
1295
	}
1296
1297
	/**
1298
	 * The XML API has some asymmetries with the JSON API. This function makes the needed
1299
	 * translations for the result content before it is converted into XML.
1300
	 * @param array $content
1301
	 * @return array
1302
	 */
1303
	private static function prepareResultForXmlApi($content) {
1304
		\reset($content);
1305
		$firstKey = \key($content);
1306
1307
		// all 'entity list' kind of responses shall have the (deprecated) total_count element
1308
		if ($firstKey == 'song' || $firstKey == 'album' || $firstKey == 'artist' || $firstKey == 'playlist'
1309
				|| $firstKey == 'tag' || $firstKey == 'podcast' || $firstKey == 'podcast_episode') {
1310
			$content = ['total_count' => \count($content[$firstKey])] + $content;
1311
		}
1312
1313
		// for some bizarre reason, the 'id' arrays have 'index' attributes in the XML format
1314
		if ($firstKey == 'id') {
1315
			$content['id'] = \array_map(function ($id, $index) {
1316
				return ['index' => $index, 'value' => $id];
1317
			}, $content['id'], \array_keys($content['id']));
1318
		}
1319
1320
		return ['root' => $content];
1321
	}
1322
1323
	private function getRequiredParam($paramName) {
1324
		$param = $this->request->getParam($paramName);
1325
1326
		if ($param === null) {
1327
			throw new AmpacheException("Required parameter '$paramName' missing", 400);
1328
		}
1329
1330
		return $param;
1331
	}
1332
}
1333
1334
/**
1335
 * Adapter class which acts like the Playlist class for the purpose of
1336
 * AmpacheController::renderPlaylists but contains all the track of the user.
1337
 */
1338
class AmpacheController_AllTracksPlaylist {
1339
	private $user;
1340
	private $trackBusinessLayer;
1341
	private $l10n;
1342
1343
	public function __construct($user, $trackBusinessLayer, $l10n) {
1344
		$this->user = $user;
1345
		$this->trackBusinessLayer = $trackBusinessLayer;
1346
		$this->l10n = $l10n;
1347
	}
1348
1349
	public function getId() {
1350
		return AmpacheController::ALL_TRACKS_PLAYLIST_ID;
1351
	}
1352
1353
	public function getName() {
1354
		return $this->l10n->t('All tracks');
1355
	}
1356
1357
	public function getTrackCount() {
1358
		return $this->trackBusinessLayer->count($this->user);
1359
	}
1360
}
1361