Passed
Push — master ( 4a5f25...dc1961 )
by Pauli
02:27
created

AmpacheController::playlist_generate()   B

Complexity

Conditions 8
Paths 40

Size

Total Lines 42
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 26
c 1
b 0
f 0
dl 0
loc 42
rs 8.4444
cc 8
nc 40
nop 9

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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