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

AmpacheController::getAllTracks()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 8
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 2
eloc 6
nc 2
nop 0
dl 0
loc 8
rs 10
c 0
b 0
f 0
1
<?php declare(strict_types=1);
2
3
/**
4
 * ownCloud - Music app
5
 *
6
 * This file is licensed under the Affero General Public License version 3 or
7
 * later. See the COPYING file.
8
 *
9
 * @author Morris Jobke <[email protected]>
10
 * @author Pauli Järvinen <[email protected]>
11
 * @copyright Morris Jobke 2013, 2014
12
 * @copyright Pauli Järvinen 2017 - 2021
13
 */
14
15
namespace OCA\Music\Controller;
16
17
use \OCP\AppFramework\Controller;
18
use \OCP\AppFramework\Http;
19
use \OCP\AppFramework\Http\JSONResponse;
20
use \OCP\AppFramework\Http\RedirectResponse;
21
use \OCP\IRequest;
22
use \OCP\IURLGenerator;
23
24
use \OCA\Music\AppFramework\BusinessLayer\BusinessLayer;
25
use \OCA\Music\AppFramework\BusinessLayer\BusinessLayerException;
26
use \OCA\Music\AppFramework\Core\Logger;
27
use \OCA\Music\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