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

AmpacheController::renderEntitiesIndex()   B

Complexity

Conditions 7
Paths 7

Size

Total Lines 9
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 7
eloc 8
nc 7
nop 2
dl 0
loc 9
rs 8.8333
c 0
b 0
f 0
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