Passed
Push — master ( dc1961...4e74cd )
by Pauli
02:00
created

AmpacheController::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 45
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 19
nc 1
nop 20
dl 0
loc 45
rs 9.6333
c 0
b 0
f 0

How to fix   Many Parameters   

Many Parameters

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

There are several approaches to avoid long parameter lists:

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