Passed
Push — feature/786_podcasts ( 7b8be7...af6910 )
by Pauli
02:22
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
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