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

AmpacheController::podcast_create()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 15
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

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