Passed
Push — feature/786_podcasts ( 7b8be7...af6910 )
by Pauli
02:22
created

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