Passed
Push — master ( 4e74cd...0361f1 )
by Pauli
02:51
created

AmpacheController::renderSongs()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 13
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
eloc 8
c 0
b 0
f 0
dl 0
loc 13
rs 10
cc 1
nc 1
nop 2
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, SortBy::Parent, $limit, $offset);
457
			foreach ($tracks as $index => &$track) {
458
				$track->setNumberOnPlaylist($index + 1);
459
			}
460
		} else {
461
			$tracks = $this->playlistBusinessLayer->getPlaylistTracks($filter, $userId, $limit, $offset);
462
		}
463
		return $this->renderSongs($tracks, $auth);
464
	}
465
466
	/**
467
	 * @AmpacheAPI
468
	 */
469
	protected function playlist_create(string $name) {
470
		$playlist = $this->playlistBusinessLayer->create($name, $this->ampacheUser->getUserId());
471
		return $this->renderPlaylists([$playlist]);
472
	}
473
474
	/**
475
	 * @AmpacheAPI
476
	 *
477
	 * @param int $filter Playlist ID
478
	 * @param ?string $name New name for the playlist
479
	 * @param ?string $items Track IDs
480
	 * @param ?string $tracks 1-based indices of the tracks
481
	 */
482
	protected function playlist_edit(int $filter, ?string $name, ?string $items, ?string $tracks) {
483
		$edited = false;
484
		$userId = $this->ampacheUser->getUserId();
485
		$playlist = $this->playlistBusinessLayer->find($filter, $userId);
486
487
		if (!empty($name)) {
488
			$playlist->setName($name);
489
			$edited = true;
490
		}
491
492
		$newTrackIds = Util::explode(',', $items);
493
		$newTrackOrdinals = Util::explode(',', $tracks);
494
495
		if (\count($newTrackIds) != \count($newTrackOrdinals)) {
496
			throw new AmpacheException("Arguments 'items' and 'tracks' must contain equal amount of elements", 400);
497
		} elseif (\count($newTrackIds) > 0) {
498
			$trackIds = $playlist->getTrackIdsAsArray();
499
500
			for ($i = 0, $count = \count($newTrackIds); $i < $count; ++$i) {
501
				$trackId = $newTrackIds[$i];
502
				if (!$this->trackBusinessLayer->exists($trackId, $userId)) {
503
					throw new AmpacheException("Invalid song ID $trackId", 404);
504
				}
505
				$trackIds[$newTrackOrdinals[$i]-1] = $trackId;
506
			}
507
508
			$playlist->setTrackIdsFromArray($trackIds);
509
			$edited = true;
510
		}
511
512
		if ($edited) {
513
			$this->playlistBusinessLayer->update($playlist);
514
			return $this->ampacheResponse(['success' => 'playlist changes saved']);
515
		} else {
516
			throw new AmpacheException('Nothing was changed', 400);
517
		}
518
	}
519
520
	/**
521
	 * @AmpacheAPI
522
	 */
523
	protected function playlist_delete(int $filter) {
524
		$this->playlistBusinessLayer->delete($filter, $this->ampacheUser->getUserId());
525
		return $this->ampacheResponse(['success' => 'playlist deleted']);
526
	}
527
528
	/**
529
	 * @AmpacheAPI
530
	 */
531
	protected function playlist_add_song(int $filter, int $song, bool $check=false) {
532
		$userId = $this->ampacheUser->getUserId();
533
		if (!$this->trackBusinessLayer->exists($song, $userId)) {
534
			throw new AmpacheException("Invalid song ID $song", 404);
535
		}
536
537
		$playlist = $this->playlistBusinessLayer->find($filter, $userId);
538
		$trackIds = $playlist->getTrackIdsAsArray();
539
540
		if ($check && \in_array($song, $trackIds)) {
541
			throw new AmpacheException("Can't add a duplicate item when check is enabled", 400);
542
		}
543
544
		$trackIds[] = $song;
545
		$playlist->setTrackIdsFromArray($trackIds);
546
		$this->playlistBusinessLayer->update($playlist);
547
		return $this->ampacheResponse(['success' => 'song added to playlist']);
548
	}
549
550
	/**
551
	 * @AmpacheAPI
552
	 *
553
	 * @param int $filter Playlist ID
554
	 * @param ?int $song Track ID
555
	 * @param ?int $track 1-based index of the track
556
	 * @param ?int $clear Value 1 erases all the songs from the playlist
557
	 */
558
	protected function playlist_remove_song(int $filter, ?int $song, ?int $track, ?int $clear) {
559
		$playlist = $this->playlistBusinessLayer->find($filter, $this->ampacheUser->getUserId());
560
561
		if ($clear === 1) {
562
			$trackIds = [];
563
			$message = 'all songs removed from playlist';
564
		} elseif ($song !== null) {
565
			$trackIds = $playlist->getTrackIdsAsArray();
566
			if (!\in_array($song, $trackIds)) {
567
				throw new AmpacheException("Song $song not found in playlist", 404);
568
			}
569
			$trackIds = Util::arrayDiff($trackIds, [$song]);
570
			$message = 'song removed from playlist';
571
		} elseif ($track !== null) {
572
			$trackIds = $playlist->getTrackIdsAsArray();
573
			if ($track < 1 || $track > \count($trackIds)) {
574
				throw new AmpacheException("Track ordinal $track is out of bounds", 404);
575
			}
576
			unset($trackIds[$track-1]);
577
			$message = 'song removed from playlist';
578
		} else {
579
			throw new AmpacheException("One of the arguments 'clear', 'song', 'track' is required", 400);
580
		}
581
582
		$playlist->setTrackIdsFromArray($trackIds);
583
		$this->playlistBusinessLayer->update($playlist);
584
		return $this->ampacheResponse(['success' => $message]);
585
	}
586
587
	/**
588
	 * @AmpacheAPI
589
	 */
590
	protected function playlist_generate(
591
			string $auth, ?string $filter, ?int $album, ?int $artist, ?int $flag,
592
			int $limit, int $offset=0, string $mode='random', string $format='song') {
593
594
		$tracks = $this->findEntities($this->trackBusinessLayer, $filter, false); // $limit and $offset are applied later
595
596
		// filter the found tracks according to the additional requirements
597
		if ($album !== null) {
598
			$tracks = \array_filter($tracks, function ($track) use ($album) {
599
				return ($track->getAlbumId() == $album);
600
			});
601
		}
602
		if ($artist !== null) {
603
			$tracks = \array_filter($tracks, function ($track) use ($artist) {
604
				return ($track->getArtistId() == $artist);
605
			});
606
		}
607
		if ($flag == 1) {
608
			$tracks = \array_filter($tracks, function ($track) {
609
				return ($track->getStarred() !== null);
610
			});
611
		}
612
		// After filtering, there may be "holes" between the array indices. Reindex the array.
613
		$tracks = \array_values($tracks);
614
615
		if ($mode == 'random') {
616
			$userId = $this->ampacheUser->getUserId();
617
			$indices = $this->random->getIndices(\count($tracks), $offset, $limit, $userId, 'ampache_playlist_generate');
618
			$tracks = Util::arrayMultiGet($tracks, $indices);
619
		} else { // 'recent', 'forgotten', 'unplayed'
620
			throw new AmpacheException("Mode '$mode' is not supported", 400);
621
		}
622
623
		switch ($format) {
624
			case 'song':
625
				return $this->renderSongs($tracks, $auth);
626
			case 'index':
627
				return $this->renderSongsIndex($tracks);
628
			case 'id':
629
				return $this->renderEntityIds($tracks);
630
			default:
631
				throw new AmpacheException("Format '$format' is not supported", 400);
632
		}
633
	}
634
635
	/**
636
	 * @AmpacheAPI
637
	 */
638
	protected function podcasts(?string $filter, ?string $include, int $limit, int $offset=0, bool $exact=false) {
639
		$channels = $this->findEntities($this->podcastChannelBusinessLayer, $filter, $exact, $limit, $offset);
640
641
		if ($include === 'episodes') {
642
			$userId = $this->ampacheUser->getUserId();
643
			$actuallyLimited = ($limit < $this->podcastChannelBusinessLayer->count($userId));
644
			$allChannelsIncluded = (!$filter && !$actuallyLimited && !$offset);
645
			$this->podcastService->injectEpisodes($channels, $userId, $allChannelsIncluded);
646
		}
647
648
		return $this->renderPodcastChannels($channels);
649
	}
650
651
	/**
652
	 * @AmpacheAPI
653
	 */
654
	protected function podcast(int $filter, ?string $include) {
655
		$userId = $this->ampacheUser->getUserId();
656
		$channel = $this->podcastChannelBusinessLayer->find($filter, $userId);
657
658
		if ($include === 'episodes') {
659
			$channel->setEpisodes($this->podcastEpisodeBusinessLayer->findAllByChannel($filter, $userId));
660
		}
661
662
		return $this->renderPodcastChannels([$channel]);
663
	}
664
665
	/**
666
	 * @AmpacheAPI
667
	 */
668
	protected function podcast_create(string $url) {
669
		$userId = $this->ampacheUser->getUserId();
670
		$result = $this->podcastService->subscribe($url, $userId);
671
672
		switch ($result['status']) {
673
			case PodcastService::STATUS_OK:
674
				return $this->renderPodcastChannels([$result['channel']]);
675
			case PodcastService::STATUS_INVALID_URL:
676
				throw new AmpacheException("Invalid URL $url", 400);
677
			case PodcastService::STATUS_INVALID_RSS:
678
				throw new AmpacheException("The document at URL $url is not a valid podcast RSS feed", 400);
679
			case PodcastService::STATUS_ALREADY_EXISTS:
680
				throw new AmpacheException('User already has this podcast channel subscribed', 400);
681
			default:
682
				throw new AmpacheException("Unexpected status code {$result['status']}", 400);
683
		}
684
	}
685
686
	/**
687
	 * @AmpacheAPI
688
	 */
689
	protected function podcast_delete(int $filter) {
690
		$userId = $this->ampacheUser->getUserId();
691
		$status = $this->podcastService->unsubscribe($filter, $userId);
692
693
		switch ($status) {
694
			case PodcastService::STATUS_OK:
695
				return $this->ampacheResponse(['success' => 'podcast deleted']);
696
			case PodcastService::STATUS_NOT_FOUND:
697
				throw new AmpacheException('Channel to be deleted not found', 404);
698
			default:
699
				throw new AmpacheException("Unexpected status code $status", 400);
700
		}
701
	}
702
703
	/**
704
	 * @AmpacheAPI
705
	 */
706
	protected function podcast_episodes(int $filter, int $limit, int $offset=0) {
707
		$userId = $this->ampacheUser->getUserId();
708
		$episodes = $this->podcastEpisodeBusinessLayer->findAllByChannel($filter, $userId, $limit, $offset);
709
		return $this->renderPodcastEpisodes($episodes);
710
	}
711
712
	/**
713
	 * @AmpacheAPI
714
	 */
715
	protected function podcast_episode(int $filter) {
716
		$userId = $this->ampacheUser->getUserId();
717
		$episode = $this->podcastEpisodeBusinessLayer->find($filter, $userId);
718
		return $this->renderPodcastEpisodes([$episode]);
719
	}
720
721
	/**
722
	 * @AmpacheAPI
723
	 */
724
	protected function update_podcast(int $id) {
725
		$userId = $this->ampacheUser->getUserId();
726
		$result = $this->podcastService->updateChannel($id, $userId);
727
728
		switch ($result['status']) {
729
			case PodcastService::STATUS_OK:
730
				$message = $result['updated'] ? 'channel was updated from the souce' : 'no changes found';
731
				return $this->ampacheResponse(['success' => $message]);
732
			case PodcastService::STATUS_NOT_FOUND:
733
				throw new AmpacheException('Channel to be updated not found', 404);
734
			case PodcastService::STATUS_INVALID_URL:
735
				throw new AmpacheException('failed to read from the channel URL', 400);
736
			case PodcastService::STATUS_INVALID_RSS:
737
				throw new AmpacheException('the document at the channel URL is not a valid podcast RSS feed', 400);
738
			default:
739
				throw new AmpacheException("Unexpected status code {$result['status']}", 400);
740
		}
741
	}
742
743
	/**
744
	 * @AmpacheAPI
745
	 */
746
	protected function tags(?string $filter, int $limit, int $offset=0, bool $exact=false) {
747
		$genres = $this->findEntities($this->genreBusinessLayer, $filter, $exact, $limit, $offset);
748
		return $this->renderTags($genres);
749
	}
750
751
	/**
752
	 * @AmpacheAPI
753
	 */
754
	protected function tag(int $filter) {
755
		$userId = $this->ampacheUser->getUserId();
756
		$genre = $this->genreBusinessLayer->find($filter, $userId);
757
		return $this->renderTags([$genre]);
758
	}
759
760
	/**
761
	 * @AmpacheAPI
762
	 */
763
	protected function tag_artists(string $auth, int $filter, int $limit, int $offset=0) {
764
		$userId = $this->ampacheUser->getUserId();
765
		$artists = $this->artistBusinessLayer->findAllByGenre($filter, $userId, $limit, $offset);
766
		return $this->renderArtists($artists, $auth);
767
	}
768
769
	/**
770
	 * @AmpacheAPI
771
	 */
772
	protected function tag_albums(string $auth, int $filter, int $limit, int $offset=0) {
773
		$userId = $this->ampacheUser->getUserId();
774
		$albums = $this->albumBusinessLayer->findAllByGenre($filter, $userId, $limit, $offset);
775
		return $this->renderAlbums($albums, $auth);
776
	}
777
778
	/**
779
	 * @AmpacheAPI
780
	 */
781
	protected function tag_songs(string $auth, int $filter, int $limit, int $offset=0) {
782
		$userId = $this->ampacheUser->getUserId();
783
		$tracks = $this->trackBusinessLayer->findAllByGenre($filter, $userId, $limit, $offset);
784
		return $this->renderSongs($tracks, $auth);
785
	}
786
787
	/**
788
	 * @AmpacheAPI
789
	 */
790
	protected function flag(string $type, int $id, bool $flag) {
791
		if (!\in_array($type, ['song', 'album', 'artist', 'podcast', 'podcast_episode'])) {
792
			throw new AmpacheException("Unsupported type $type", 400);
793
		}
794
795
		$userId = $this->ampacheUser->getUserId();
796
		$businessLayer = $this->getBusinessLayer($type);
797
		if ($flag) {
798
			$modifiedCount = $businessLayer->setStarred([$id], $userId);
799
			$message = "flag ADDED to $type $id";
800
		} else {
801
			$modifiedCount = $businessLayer->unsetStarred([$id], $userId);
802
			$message = "flag REMOVED from $type $id";
803
		}
804
805
		if ($modifiedCount > 0) {
806
			return $this->ampacheResponse(['success' => $message]);
807
		} else {
808
			throw new AmpacheException("The $type $id was not found", 404);
809
		}
810
	}
811
812
	/**
813
	 * @AmpacheAPI
814
	 */
815
	protected function download(int $id, string $type='song') {
816
		// request param `format` is ignored
817
		$userId = $this->ampacheUser->getUserId();
818
819
		if ($type === 'song') {
820
			try {
821
				$track = $this->trackBusinessLayer->find($id, $userId);
822
			} catch (BusinessLayerException $e) {
823
				return new ErrorResponse(Http::STATUS_NOT_FOUND, $e->getMessage());
824
			}
825
826
			$file = $this->rootFolder->getUserFolder($userId)->getById($track->getFileId())[0] ?? null;
827
828
			if ($file instanceof \OCP\Files\File) {
829
				return new FileStreamResponse($file);
830
			} else {
831
				return new ErrorResponse(Http::STATUS_NOT_FOUND);
832
			}
833
		} elseif ($type === 'podcast') {
834
			$episode = $this->podcastEpisodeBusinessLayer->find($id, $userId);
835
			return new RedirectResponse($episode->getStreamUrl());
836
		} else {
837
			throw new AmpacheException("Unsupported type '$type'", 400);
838
		}
839
	}
840
841
	/**
842
	 * @AmpacheAPI
843
	 */
844
	protected function stream(int $id, ?int $offset) {
845
		// request params `bitrate`, `format`, and `length` are ignored
846
847
		// This is just a dummy implementation. We don't support transcoding or streaming
848
		// from a time offset.
849
		// All the other unsupported arguments are just ignored, but a request with an offset
850
		// is responded with an error. This is becuase the client would probably work in an
851
		// unexpected way if it thinks it's streaming from offset but actually it is streaming
852
		// from the beginning of the file. Returning an error gives the client a chance to fallback
853
		// to other methods of seeking.
854
		if ($offset !== null) {
855
			throw new AmpacheException('Streaming with time offset is not supported', 400);
856
		}
857
858
		return $this->download($id);
859
	}
860
861
	/**
862
	 * @AmpacheAPI
863
	 */
864
	protected function get_art(string $type, int $id) {
865
		if (!\in_array($type, ['song', 'album', 'artist', 'podcast'])) {
866
			throw new AmpacheException("Unsupported type $type", 400);
867
		}
868
869
		if ($type === 'song') {
870
			// map song to its parent album
871
			$id = $this->trackBusinessLayer->find($id, $this->ampacheUser->getUserId())->getAlbumId();
872
			$type = 'album';
873
		}
874
875
		return $this->getCover($id, $this->getBusinessLayer($type));
876
	}
877
878
	/********************
879
	 * Helper functions *
880
	 ********************/
881
882
	private function getBusinessLayer($type) {
883
		switch ($type) {
884
			case 'song':			return $this->trackBusinessLayer;
885
			case 'album':			return $this->albumBusinessLayer;
886
			case 'artist':			return $this->artistBusinessLayer;
887
			case 'playlist':		return $this->playlistBusinessLayer;
888
			case 'podcast':			return $this->podcastChannelBusinessLayer;
889
			case 'podcast_episode':	return $this->podcastEpisodeBusinessLayer;
890
			case 'tag':				return $this->genreBusinessLayer;
891
			default:				throw new AmpacheException("Unsupported type $type", 400);
892
		}
893
	}
894
895
	private function renderEntities($entities, $type, $auth) {
896
		switch ($type) {
897
			case 'song':			return $this->renderSongs($entities, $auth);
898
			case 'album':			return $this->renderAlbums($entities, $auth);
899
			case 'artist':			return $this->renderArtists($entities, $auth);
900
			case 'playlist':		return $this->renderPlaylists($entities);
901
			case 'podcast':			return $this->renderPodcastChannels($entities);
902
			case 'podcast_episode':	return $this->renderPodcastEpisodes($entities);
903
			case 'tag':				return $this->renderTags($entities);
904
			default:				throw new AmpacheException("Unsupported type $type", 400);
905
		}
906
	}
907
908
	private function renderEntitiesIndex($entities, $type) {
909
		switch ($type) {
910
			case 'song':			return $this->renderSongsIndex($entities);
911
			case 'album':			return $this->renderAlbumsIndex($entities);
912
			case 'artist':			return $this->renderArtistsIndex($entities);
913
			case 'playlist':		return $this->renderPlaylistsIndex($entities);
914
			case 'podcast':			return $this->renderPodcastChannelsIndex($entities);
915
			case 'podcast_episode':	return $this->renderPodcastEpisodesIndex($entities);
916
			default:				throw new AmpacheException("Unsupported type $type", 400);
917
		}
918
	}
919
920
	private function getAppNameAndVersion() {
921
		$vendor = 'owncloud/nextcloud'; // this should get overridden by the next 'include'
922
		include \OC::$SERVERROOT . '/version.php';
923
924
		// Note: the following is deprecated since NC14 but the replacement
925
		// \OCP\App\IAppManager::getAppVersion is not available before NC14.
926
		$appVersion = \OCP\App::getAppVersion($this->appName);
927
928
		return "$vendor {$this->appName} $appVersion";
929
	}
930
931
	private function getCover(int $entityId, BusinessLayer $businessLayer) {
932
		$userId = $this->ampacheUser->getUserId();
933
		$userFolder = $this->rootFolder->getUserFolder($userId);
934
935
		try {
936
			$entity = $businessLayer->find($entityId, $userId);
937
			$coverData = $this->coverHelper->getCover($entity, $userId, $userFolder);
938
			if ($coverData !== null) {
939
				return new FileResponse($coverData);
940
			}
941
		} catch (BusinessLayerException $e) {
942
			return new ErrorResponse(Http::STATUS_NOT_FOUND, 'entity not found');
943
		}
944
945
		return new ErrorResponse(Http::STATUS_NOT_FOUND, 'entity has no cover');
946
	}
947
948
	private function checkHandshakeTimestamp(int $timestamp, int $currentTime) {
949
		if ($timestamp === 0) {
950
			throw new AmpacheException('Invalid Login - cannot parse time', 401);
951
		}
952
		if ($timestamp < ($currentTime - self::SESSION_EXPIRY_TIME)) {
953
			throw new AmpacheException('Invalid Login - session is outdated', 401);
954
		}
955
		// Allow the timestamp to be at maximum 10 minutes in the future. The client may use its
956
		// own system clock to generate the timestamp and that may differ from the server's time.
957
		if ($timestamp > $currentTime + 600) {
958
			throw new AmpacheException('Invalid Login - timestamp is in future', 401);
959
		}
960
	}
961
962
	private function checkHandshakeAuthentication($user, $timestamp, $auth) {
963
		$hashes = $this->ampacheUserMapper->getPasswordHashes($user);
964
965
		foreach ($hashes as $hash) {
966
			$expectedHash = \hash('sha256', $timestamp . $hash);
967
968
			if ($expectedHash === $auth) {
969
				return;
970
			}
971
		}
972
973
		throw new AmpacheException('Invalid Login - passphrase does not match', 401);
974
	}
975
976
	private function startNewSession($user, $expiryDate) {
977
		$token = Random::secure(16);
978
979
		// create new session
980
		$session = new AmpacheSession();
981
		$session->setUserId($user);
982
		$session->setToken($token);
983
		$session->setExpiry($expiryDate);
984
985
		// save session
986
		$this->ampacheSessionMapper->insert($session);
987
988
		return $token;
989
	}
990
991
	private function findEntities(
992
			BusinessLayer $businessLayer, $filter, $exact, $limit=null, $offset=null, $add=null, $update=null) : array {
993
994
		$userId = $this->ampacheUser->getUserId();
995
996
		// It's not documented, but Ampache supports also specifying date range on `add` and `update` parameters
997
		// by using '/' as separator. If there is no such separator, then the value is used as a lower limit.
998
		$add = Util::explode('/', $add);
999
		$update = Util::explode('/', $update);
1000
		$addMin = $add[0] ?? null;
1001
		$addMax = $add[1] ?? null;
1002
		$updateMin = $update[0] ?? null;
1003
		$updateMax = $update[1] ?? null;
1004
1005
		if ($filter) {
1006
			$fuzzy = !((boolean) $exact);
1007
			return $businessLayer->findAllByName($filter, $userId, $fuzzy, $limit, $offset, $addMin, $addMax, $updateMin, $updateMax);
1008
		} else {
1009
			return $businessLayer->findAll($userId, SortBy::Name, $limit, $offset, $addMin, $addMax, $updateMin, $updateMax);
1010
		}
1011
	}
1012
1013
	private function createAmpacheActionUrl($action, $id, $auth, $type=null) {
1014
		$api = $this->jsonMode ? 'music.ampache.jsonApi' : 'music.ampache.xmlApi';
1015
		return $this->urlGenerator->getAbsoluteURL($this->urlGenerator->linkToRoute($api))
1016
				. "?action=$action&id=$id&auth=$auth"
1017
				. (!empty($type) ? "&type=$type" : '');
1018
	}
1019
1020
	private function createCoverUrl($entity, $auth) {
1021
		if ($entity instanceof Album) {
1022
			$type = 'album';
1023
		} elseif ($entity instanceof Artist) {
1024
			$type = 'artist';
1025
		} else {
1026
			throw new AmpacheException('unexpeted entity type for cover image', 500);
1027
		}
1028
1029
		if ($entity->getCoverFileId()) {
1030
			return $this->createAmpacheActionUrl("get_art", $entity->getId(), $auth, $type);
1031
		} else {
1032
			return '';
1033
		}
1034
	}
1035
1036
	/**
1037
	 * @param int $index
1038
	 * @param int|null $offset
1039
	 * @param int|null $limit
1040
	 * @return boolean
1041
	 */
1042
	private static function indexIsWithinOffsetAndLimit($index, $offset, $limit) {
1043
		$offset = \intval($offset); // missing offset is interpreted as 0-offset
1044
		return ($limit === null) || ($index >= $offset && $index < $offset + $limit);
1045
	}
1046
1047
	private function renderArtists($artists, $auth) {
1048
		$userId = $this->ampacheUser->getUserId();
1049
		$genreMap = Util::createIdLookupTable($this->genreBusinessLayer->findAll($userId));
1050
1051
		return $this->ampacheResponse([
1052
			'artist' => \array_map(function (Artist $artist) use ($userId, $genreMap, $auth) {
1053
				return [
1054
					'id' => (string)$artist->getId(),
1055
					'name' => $artist->getNameString($this->l10n),
1056
					'albums' => $this->albumBusinessLayer->countByArtist($artist->getId()),
1057
					'songs' => $this->trackBusinessLayer->countByArtist($artist->getId()),
1058
					'art' => $this->createCoverUrl($artist, $auth),
1059
					'rating' => 0,
1060
					'preciserating' => 0,
1061
					'flag' => empty($artist->getStarred()) ? 0 : 1,
1062
					'tag' => \array_map(function ($genreId) use ($genreMap) {
1063
						return [
1064
							'id' => (string)$genreId,
1065
							'value' => $genreMap[$genreId]->getNameString($this->l10n),
1066
							'count' => 1
1067
						];
1068
					}, $this->trackBusinessLayer->getGenresByArtistId($artist->getId(), $userId))
1069
				];
1070
			}, $artists)
1071
		]);
1072
	}
1073
1074
	private function renderAlbums($albums, $auth) {
1075
		return $this->ampacheResponse([
1076
			'album' => \array_map(function (Album $album) use ($auth) {
1077
				return [
1078
					'id' => (string)$album->getId(),
1079
					'name' => $album->getNameString($this->l10n),
1080
					'artist' => [
1081
						'id' => (string)$album->getAlbumArtistId(),
1082
						'value' => $album->getAlbumArtistNameString($this->l10n)
1083
					],
1084
					'tracks' => $this->trackBusinessLayer->countByAlbum($album->getId()),
1085
					'rating' => 0,
1086
					'year' => $album->yearToAPI(),
1087
					'art' => $this->createCoverUrl($album, $auth),
1088
					'preciserating' => 0,
1089
					'flag' => empty($album->getStarred()) ? 0 : 1,
1090
					'tag' => \array_map(function ($genre) {
1091
						return [
1092
							'id' => (string)$genre->getId(),
1093
							'value' => $genre->getNameString($this->l10n),
1094
							'count' => 1
1095
						];
1096
					}, $album->getGenres() ?? [])
1097
				];
1098
			}, $albums)
1099
		]);
1100
	}
1101
1102
	private function renderSongs(array $tracks, string $auth) {
1103
		$userId = $this->ampacheUser->getUserId();
1104
		$this->albumBusinessLayer->injectAlbumsToTracks($tracks, $userId);
1105
1106
		$createPlayUrl = function(Track $track) use ($auth) : string {
1107
			return $this->createAmpacheActionUrl('download', $track->getId(), $auth);
1108
		};
1109
		$createImageUrl = function(Track $track) use ($auth) : string {
1110
			return $this->createCoverUrl($track->getAlbum(), $auth);
1111
		};
1112
1113
		return $this->ampacheResponse([
1114
			'song' => Util::arrayMapMethod($tracks, 'toAmpacheApi', [$this->l10n, $createPlayUrl, $createImageUrl])
1115
		]);
1116
	}
1117
1118
	private function renderPlaylists(array $playlists) {
1119
		return $this->ampacheResponse([
1120
			'playlist' => Util::arrayMapMethod($playlists, 'toAmpacheApi')
1121
		]);
1122
	}
1123
1124
	private function renderPodcastChannels(array $channels) {
1125
		return $this->ampacheResponse([
1126
			'podcast' => Util::arrayMapMethod($channels, 'toAmpacheApi')
1127
		]);
1128
	}
1129
1130
	private function renderPodcastEpisodes(array $episodes) {
1131
		return $this->ampacheResponse([
1132
			'podcast_episode' => Util::arrayMapMethod($episodes, 'toAmpacheApi')
1133
		]);
1134
	}
1135
1136
	private function renderTags($genres) {
1137
		return $this->ampacheResponse([
1138
			'tag' => Util::arrayMapMethod($genres, 'toAmpacheApi', [$this->l10n])
1139
		]);
1140
	}
1141
1142
	private function renderSongsIndex($tracks) {
1143
		return $this->ampacheResponse([
1144
			'song' => \array_map(function ($track) {
1145
				return [
1146
					'id' => (string)$track->getId(),
1147
					'title' => $track->getTitle(),
1148
					'name' => $track->getTitle(),
1149
					'artist' => [
1150
						'id' => (string)$track->getArtistId(),
1151
						'value' => $track->getArtistNameString($this->l10n)
1152
					],
1153
					'album' => [
1154
						'id' => (string)$track->getAlbumId(),
1155
						'value' => $track->getAlbumNameString($this->l10n)
1156
					]
1157
				];
1158
			}, $tracks)
1159
		]);
1160
	}
1161
1162
	private function renderAlbumsIndex($albums) {
1163
		return $this->ampacheResponse([
1164
			'album' => \array_map(function ($album) {
1165
				return [
1166
					'id' => (string)$album->getId(),
1167
					'name' => $album->getNameString($this->l10n),
1168
					'artist' => [
1169
						'id' => (string)$album->getAlbumArtistId(),
1170
						'value' => $album->getAlbumArtistNameString($this->l10n)
1171
					]
1172
				];
1173
			}, $albums)
1174
		]);
1175
	}
1176
1177
	private function renderArtistsIndex($artists) {
1178
		return $this->ampacheResponse([
1179
			'artist' => \array_map(function ($artist) {
1180
				$userId = $this->ampacheUser->getUserId();
1181
				$albums = $this->albumBusinessLayer->findAllByArtist($artist->getId(), $userId);
1182
1183
				return [
1184
					'id' => (string)$artist->getId(),
1185
					'name' => $artist->getNameString($this->l10n),
1186
					'album' => \array_map(function ($album) {
1187
						return [
1188
							'id' => (string)$album->getId(),
1189
							'value' => $album->getNameString($this->l10n)
1190
						];
1191
					}, $albums)
1192
				];
1193
			}, $artists)
1194
		]);
1195
	}
1196
1197
	private function renderPlaylistsIndex($playlists) {
1198
		return $this->ampacheResponse([
1199
			'playlist' => \array_map(function ($playlist) {
1200
				return [
1201
					'id' => (string)$playlist->getId(),
1202
					'name' => $playlist->getName(),
1203
					'playlisttrack' => $playlist->getTrackIdsAsArray()
1204
				];
1205
			}, $playlists)
1206
		]);
1207
	}
1208
1209
	private function renderPodcastChannelsIndex(array $channels) {
1210
		// The v4 API spec does not give any examples of this, and the v5 example is almost identical to the v4 "normal" result
1211
		return $this->renderPodcastChannels($channels);
1212
	}
1213
1214
	private function renderPodcastEpisodesIndex(array $episodes) {
1215
		// The v4 API spec does not give any examples of this, and the v5 example is almost identical to the v4 "normal" result
1216
		return $this->renderPodcastEpisodes($episodes);
1217
	}
1218
1219
	private function renderEntityIds($entities) {
1220
		return $this->ampacheResponse(['id' => Util::extractIds($entities)]);
1221
	}
1222
1223
	/**
1224
	 * Array is considered to be "indexed" if its first element has numerical key.
1225
	 * Empty array is considered to be "indexed".
1226
	 * @param array $array
1227
	 */
1228
	private static function arrayIsIndexed(array $array) {
1229
		\reset($array);
1230
		return empty($array) || \is_int(\key($array));
1231
	}
1232
1233
	/**
1234
	 * The JSON API has some asymmetries with the XML API. This function makes the needed
1235
	 * translations for the result content before it is converted into JSON.
1236
	 * @param array $content
1237
	 * @return array
1238
	 */
1239
	private static function prepareResultForJsonApi($content) {
1240
		// In all responses returning an array of library entities, the root node is anonymous.
1241
		// Unwrap the outermost array if it is an associative array with a single array-type value.
1242
		if (\count($content) === 1 && !self::arrayIsIndexed($content)
1243
				&& \is_array(\current($content)) && self::arrayIsIndexed(\current($content))) {
1244
			$content = \array_pop($content);
1245
		}
1246
1247
		// The key 'value' has a special meaning on XML responses, as it makes the corresponding value
1248
		// to be treated as text content of the parent element. In the JSON API, these are mostly
1249
		// substituted with property 'name', but error responses use the property 'message', instead.
1250
		if (\array_key_exists('error', $content)) {
1251
			$content = Util::convertArrayKeys($content, ['value' => 'message']);
1252
		} else {
1253
			$content = Util::convertArrayKeys($content, ['value' => 'name']);
1254
		}
1255
		return $content;
1256
	}
1257
1258
	/**
1259
	 * The XML API has some asymmetries with the JSON API. This function makes the needed
1260
	 * translations for the result content before it is converted into XML.
1261
	 * @param array $content
1262
	 * @return array
1263
	 */
1264
	private static function prepareResultForXmlApi($content) {
1265
		\reset($content);
1266
		$firstKey = \key($content);
1267
1268
		// all 'entity list' kind of responses shall have the (deprecated) total_count element
1269
		if ($firstKey == 'song' || $firstKey == 'album' || $firstKey == 'artist' || $firstKey == 'playlist'
1270
				|| $firstKey == 'tag' || $firstKey == 'podcast' || $firstKey == 'podcast_episode') {
1271
			$content = ['total_count' => \count($content[$firstKey])] + $content;
1272
		}
1273
1274
		// for some bizarre reason, the 'id' arrays have 'index' attributes in the XML format
1275
		if ($firstKey == 'id') {
1276
			$content['id'] = \array_map(function ($id, $index) {
1277
				return ['index' => $index, 'value' => $id];
1278
			}, $content['id'], \array_keys($content['id']));
1279
		}
1280
1281
		return ['root' => $content];
1282
	}
1283
1284
}
1285
1286
/**
1287
 * Adapter class which acts like the Playlist class for the purpose of
1288
 * AmpacheController::renderPlaylists but contains all the track of the user.
1289
 */
1290
class AmpacheController_AllTracksPlaylist extends Playlist {
1291
	private $trackBusinessLayer;
1292
	private $l10n;
1293
1294
	public function __construct($userId, $trackBusinessLayer, $l10n) {
1295
		$this->userId = $userId;
1296
		$this->trackBusinessLayer = $trackBusinessLayer;
1297
		$this->l10n = $l10n;
1298
	}
1299
1300
	public function getId() {
1301
		return AmpacheController::ALL_TRACKS_PLAYLIST_ID;
1302
	}
1303
1304
	public function getName() {
1305
		return $this->l10n->t('All tracks');
1306
	}
1307
1308
	public function getTrackCount() {
1309
		return $this->trackBusinessLayer->count($this->userId);
1310
	}
1311
}
1312