Passed
Pull Request — master (#1078)
by Pauli
20:11 queued 15:09
created

AmpacheController::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 44
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 1
eloc 21
c 2
b 0
f 0
nc 1
nop 22
dl 0
loc 44
rs 9.584

How to fix   Many Parameters   

Many Parameters

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

There are several approaches to avoid long parameter lists:

1
<?php declare(strict_types=1);
2
3
/**
4
 * ownCloud - Music app
5
 *
6
 * This file is licensed under the Affero General Public License version 3 or
7
 * later. See the COPYING file.
8
 *
9
 * @author Morris Jobke <[email protected]>
10
 * @author Pauli Järvinen <[email protected]>
11
 * @copyright Morris Jobke 2013, 2014
12
 * @copyright Pauli Järvinen 2017 - 2023
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\AppFramework\Http\Response;
22
use OCP\IConfig;
23
use OCP\IL10N;
24
use OCP\IRequest;
25
use OCP\IURLGenerator;
26
27
use OCA\Music\AppFramework\BusinessLayer\BusinessLayer;
28
use OCA\Music\AppFramework\BusinessLayer\BusinessLayerException;
29
use OCA\Music\AppFramework\Core\Logger;
30
use OCA\Music\AppFramework\Utility\MethodAnnotationReader;
31
use OCA\Music\AppFramework\Utility\RequestParameterExtractor;
32
use OCA\Music\AppFramework\Utility\RequestParameterExtractorException;
33
34
use OCA\Music\BusinessLayer\AlbumBusinessLayer;
35
use OCA\Music\BusinessLayer\ArtistBusinessLayer;
36
use OCA\Music\BusinessLayer\BookmarkBusinessLayer;
37
use OCA\Music\BusinessLayer\GenreBusinessLayer;
38
use OCA\Music\BusinessLayer\Library;
39
use OCA\Music\BusinessLayer\PlaylistBusinessLayer;
40
use OCA\Music\BusinessLayer\PodcastChannelBusinessLayer;
41
use OCA\Music\BusinessLayer\PodcastEpisodeBusinessLayer;
42
use OCA\Music\BusinessLayer\RadioStationBusinessLayer;
43
use OCA\Music\BusinessLayer\TrackBusinessLayer;
44
45
use OCA\Music\Db\Album;
46
use OCA\Music\Db\AmpacheSession;
47
use OCA\Music\Db\Artist;
48
use OCA\Music\Db\BaseMapper;
49
use OCA\Music\Db\Bookmark;
50
use OCA\Music\Db\Entity;
51
use OCA\Music\Db\Genre;
52
use OCA\Music\Db\RadioStation;
53
use OCA\Music\Db\MatchMode;
54
use OCA\Music\Db\Playlist;
55
use OCA\Music\Db\PodcastChannel;
56
use OCA\Music\Db\PodcastEpisode;
57
use OCA\Music\Db\SortBy;
58
use OCA\Music\Db\Track;
59
60
use OCA\Music\Http\ErrorResponse;
61
use OCA\Music\Http\FileResponse;
62
use OCA\Music\Http\FileStreamResponse;
63
use OCA\Music\Http\XmlResponse;
64
65
use OCA\Music\Middleware\AmpacheException;
66
67
use OCA\Music\Utility\AmpacheImageService;
68
use OCA\Music\Utility\AppInfo;
69
use OCA\Music\Utility\CoverHelper;
70
use OCA\Music\Utility\LastfmService;
71
use OCA\Music\Utility\LibrarySettings;
72
use OCA\Music\Utility\PodcastService;
73
use OCA\Music\Utility\Random;
74
use OCA\Music\Utility\Util;
75
76
class AmpacheController extends Controller {
77
	private $config;
78
	private $l10n;
79
	private $urlGenerator;
80
	private $albumBusinessLayer;
81
	private $artistBusinessLayer;
82
	private $bookmarkBusinessLayer;
83
	private $genreBusinessLayer;
84
	private $playlistBusinessLayer;
85
	private $podcastChannelBusinessLayer;
86
	private $podcastEpisodeBusinessLayer;
87
	private $radioStationBusinessLayer;
88
	private $trackBusinessLayer;
89
	private $library;
90
	private $podcastService;
91
	private $imageService;
92
	private $coverHelper;
93
	private $lastfmService;
94
	private $librarySettings;
95
	private $random;
96
	private $logger;
97
98
	private $jsonMode;
99
	private $session;
100
	private $namePrefixes;
101
102
	const ALL_TRACKS_PLAYLIST_ID = -1;
103
	const API4_VERSION = '440000';
104
	const API5_VERSION = '560000';
105
	const API6_VERSION = '600000';
106
	const API_MIN_COMPATIBLE_VERSION = '350001';
107
108
	public function __construct(string $appname,
109
								IRequest $request,
110
								IConfig $config,
111
								IL10N $l10n,
112
								IURLGenerator $urlGenerator,
113
								AlbumBusinessLayer $albumBusinessLayer,
114
								ArtistBusinessLayer $artistBusinessLayer,
115
								BookmarkBusinessLayer $bookmarkBusinessLayer,
116
								GenreBusinessLayer $genreBusinessLayer,
117
								PlaylistBusinessLayer $playlistBusinessLayer,
118
								PodcastChannelBusinessLayer $podcastChannelBusinessLayer,
119
								PodcastEpisodeBusinessLayer $podcastEpisodeBusinessLayer,
120
								RadioStationBusinessLayer $radioStationBusinessLayer,
121
								TrackBusinessLayer $trackBusinessLayer,
122
								Library $library,
123
								PodcastService $podcastService,
124
								AmpacheImageService $imageService,
125
								CoverHelper $coverHelper,
126
								LastfmService $lastfmService,
127
								LibrarySettings $librarySettings,
128
								Random $random,
129
								Logger $logger) {
130
		parent::__construct($appname, $request);
131
132
		$this->config = $config;
133
		$this->l10n = $l10n;
134
		$this->urlGenerator = $urlGenerator;
135
		$this->albumBusinessLayer = $albumBusinessLayer;
136
		$this->artistBusinessLayer = $artistBusinessLayer;
137
		$this->bookmarkBusinessLayer = $bookmarkBusinessLayer;
138
		$this->genreBusinessLayer = $genreBusinessLayer;
139
		$this->playlistBusinessLayer = $playlistBusinessLayer;
140
		$this->podcastChannelBusinessLayer = $podcastChannelBusinessLayer;
141
		$this->podcastEpisodeBusinessLayer = $podcastEpisodeBusinessLayer;
142
		$this->radioStationBusinessLayer = $radioStationBusinessLayer;
143
		$this->trackBusinessLayer = $trackBusinessLayer;
144
		$this->library = $library;
145
		$this->podcastService = $podcastService;
146
		$this->imageService = $imageService;
147
		$this->coverHelper = $coverHelper;
148
		$this->lastfmService = $lastfmService;
149
		$this->librarySettings = $librarySettings;
150
		$this->random = $random;
151
		$this->logger = $logger;
152
	}
153
154
	public function setJsonMode(bool $useJsonMode) : void {
155
		$this->jsonMode = $useJsonMode;
156
	}
157
158
	public function setSession(AmpacheSession $session) : void {
159
		$this->session = $session;
160
		$this->namePrefixes = $this->librarySettings->getIgnoredArticles($session->getUserId());
161
	}
162
163
	public function ampacheResponse(array $content) : Response {
164
		if ($this->jsonMode) {
165
			return new JSONResponse($this->prepareResultForJsonApi($content));
166
		} else {
167
			return new XmlResponse($this->prepareResultForXmlApi($content), ['id', 'index', 'count', 'code', 'errorCode'], true, true);
168
		}
169
	}
170
171
	public function ampacheErrorResponse(int $code, string $message) : Response {
172
		$this->logger->log($message, 'debug');
173
174
		if ($this->apiMajorVersion() > 4) {
175
			$code = $this->mapApiV4ErrorToV5($code);
176
			$content = [
177
				'error' => [
178
					'errorCode' => (string)$code,
179
					'errorAction' => $this->request->getParam('action'),
180
					'errorType' => 'system',
181
					'errorMessage' => $message
182
				]
183
			];
184
		} else {
185
			$content = [
186
				'error' => [
187
					'code' => (string)$code,
188
					'value' => $message
189
				]
190
			];
191
		}
192
		return $this->ampacheResponse($content);
193
	}
194
195
	/**
196
	 * @NoAdminRequired
197
	 * @PublicPage
198
	 * @NoCSRFRequired
199
	 * @NoSameSiteCookieRequired
200
	 */
201
	public function xmlApi(string $action) : Response {
202
		// differentation between xmlApi and jsonApi is made already by the middleware
203
		return $this->dispatch($action);
204
	}
205
206
	/**
207
	 * @NoAdminRequired
208
	 * @PublicPage
209
	 * @NoCSRFRequired
210
	 * @NoSameSiteCookieRequired
211
	 */
212
	public function jsonApi(string $action) : Response {
213
		// differentation between xmlApi and jsonApi is made already by the middleware
214
		return $this->dispatch($action);
215
	}
216
217
	protected function dispatch(string $action) : Response {
218
		$this->logger->log("Ampache action '$action' requested", 'debug');
219
220
		// Allow calling any functions annotated to be part of the API
221
		if (\method_exists($this, $action)) {
222
			$annotationReader = new MethodAnnotationReader($this, $action);
223
			if ($annotationReader->hasAnnotation('AmpacheAPI')) {
224
				// custom "filter" which modifies the value of the request argument `limit`
225
				$limitFilter = function(?string $value) : int {
226
					// Any non-integer values and integer value 0 are interpreted as "no limit".
227
					// On the other hand, the API spec mandates limiting responses to 5000 entries
228
					// even if no limit or larger limit has been passed.
229
					$value = (int)$value;
230
					if ($value <= 0) {
231
						$value = 5000;
232
					}
233
					return \min($value, 5000);
234
				};
235
236
				$parameterExtractor = new RequestParameterExtractor($this->request, ['limit' => $limitFilter]);
237
				try {
238
					$parameterValues = $parameterExtractor->getParametersForMethod($this, $action);
239
				} catch (RequestParameterExtractorException $ex) {
240
					throw new AmpacheException($ex->getMessage(), 400);
241
				}
242
				$response = \call_user_func_array([$this, $action], $parameterValues);
243
				// The API methods may return either a Response object or an array, which should be converted to Response
244
				if (!($response instanceof Response)) {
245
					$response = $this->ampacheResponse($response);
246
				}
247
				return $response;
248
			}
249
		}
250
251
		// No method was found for this action
252
		$this->logger->log("Unsupported Ampache action '$action' requested", 'warn');
253
		throw new AmpacheException('Action not supported', 405);
254
	}
255
256
	/***********************
257
	 * Ampahce API methods *
258
	 ***********************/
259
260
	/**
261
	 * Get the handshake result. The actual user authentication and session creation logic has happened prior to calling
262
	 * this in the class AmpacheMiddleware.
263
	 * 
264
	 * @AmpacheAPI
265
	 */
266
	 protected function handshake() : array {
267
		$user = $this->session->getUserId();
268
		$updateTime = \max($this->library->latestUpdateTime($user), $this->playlistBusinessLayer->latestUpdateTime($user));
269
		$addTime = \max($this->library->latestInsertTime($user), $this->playlistBusinessLayer->latestInsertTime($user));
270
		$genresKey = $this->genreKey() . 's';
271
		$playlistCount = $this->playlistBusinessLayer->count($user);
272
		
273
		return [
274
			'session_expire' => \date('c', $this->session->getExpiry()),
275
			'auth' => $this->session->getToken(),
276
			'api' => $this->apiVersionString(),
277
			'update' => $updateTime->format('c'),
278
			'add' => $addTime->format('c'),
279
			'clean' => \date('c', \time()), // TODO: actual time of the latest item removal
280
			'songs' => $this->trackBusinessLayer->count($user),
281
			'artists' => $this->artistBusinessLayer->count($user),
282
			'albums' => $this->albumBusinessLayer->count($user),
283
			'playlists' => $playlistCount,
284
			'searches' => 1, // "All tracks"
285
			'playlists_searches' => $playlistCount + 1,
286
			'podcasts' => $this->podcastChannelBusinessLayer->count($user),
287
			'podcast_episodes' => $this->podcastEpisodeBusinessLayer->count($user),
288
			'live_streams' => $this->radioStationBusinessLayer->count($user),
289
			$genresKey => $this->genreBusinessLayer->count($user),
290
			'videos' => 0,
291
			'catalogs' => 0,
292
			'shares' => 0,
293
			'licenses' => 0,
294
			'labels' => 0
295
		];
296
	}
297
298
	/**
299
	 * Get the result for the 'goodbye' command. The actual logout is handled by AmpacheMiddleware.
300
	 * 
301
	 * @AmpacheAPI
302
	 */
303
	protected function goodbye() : array {
304
		return ['success' => "goodbye: {$this->session->getToken()}"];
305
	}
306
307
	/**
308
	 * @AmpacheAPI
309
	 */
310
	protected function ping() : array {
311
		$response = [
312
			'server' => $this->getAppNameAndVersion(),
313
			'version' => self::API6_VERSION,
314
			'compatible' => self::API_MIN_COMPATIBLE_VERSION
315
		];
316
317
		if ($this->session) {
318
			// in case ping is called within a valid session, the response will contain also the "handshake fields"
319
			$response += $this->handshake();
320
		}
321
322
		return $response;
323
	}
324
325
	/**
326
	 * @AmpacheAPI
327
	 */
328
	protected function get_indexes(string $type, ?string $filter, ?string $add, ?string $update, int $limit, int $offset=0) : array {
329
		if ($type === 'album_artist') {
330
			if (!empty($filter) || !empty($add) || !empty($update)) {
331
				throw new AmpacheException("Arguments 'filter', 'add', and 'update' are not supported for the type 'album_artist'", 400);
332
			}
333
			$entities = $this->artistBusinessLayer->findAllHavingAlbums($this->session->getUserId(), SortBy::Name, $limit, $offset);
334
			$type = 'artist';
335
		} else {
336
			$businessLayer = $this->getBusinessLayer($type);
337
			$entities = $this->findEntities($businessLayer, $filter, false, $limit, $offset, $add, $update);
338
		}
339
		return $this->renderEntitiesIndex($entities, $type);
340
	}
341
342
	/**
343
	 * @AmpacheAPI
344
	 */
345
	protected function stats(string $type, ?string $filter, int $limit, int $offset=0) : array {
346
		$userId = $this->session->getUserId();
347
348
		// Support for API v3.x: Originally, there was no 'filter' argument and the 'type'
349
		// argument had that role. The action only supported albums in this old format.
350
		// The 'filter' argument was added and role of 'type' changed in API v4.0.
351
		if (empty($filter)) {
352
			$filter = $type;
353
			$type = 'album';
354
		}
355
356
		// Note: In addition to types specified in APIv6, we support also types 'genre' and 'live_stream'
357
		// as that's possible without extra effort. All types don't support all possible filters.
358
		$businessLayer = $this->getBusinessLayer($type);
359
360
		$getEntitiesIfSupported = function(
361
				BusinessLayer $businessLayer, string $method, string $userId,
362
				int $limit, int $offset) use ($type, $filter) {
363
			if (\method_exists($businessLayer, $method)) {
364
				return $businessLayer->$method($userId, $limit, $offset);
365
			} else {
366
				throw new AmpacheException("Filter $filter not supported for type $type", 400);
367
			}
368
		};
369
370
		switch ($filter) {
371
			case 'newest':
372
				$entities = $businessLayer->findAll($userId, SortBy::Newest, $limit, $offset);
373
				break;
374
			case 'flagged':
375
				$entities = $businessLayer->findAllStarred($userId, $limit, $offset);
376
				break;
377
			case 'random':
378
				$entities = $businessLayer->findAll($userId, SortBy::None);
379
				$indices = $this->random->getIndices(\count($entities), $offset, $limit, $userId, 'ampache_stats_'.$type);
380
				$entities = Util::arrayMultiGet($entities, $indices);
381
				break;
382
			case 'frequent':
383
				$entities = $getEntitiesIfSupported($businessLayer, 'findFrequentPlay', $userId, $limit, $offset);
384
				break;
385
			case 'recent':
386
				$entities = $getEntitiesIfSupported($businessLayer, 'findRecentPlay', $userId, $limit, $offset);
387
				break;
388
			case 'forgotten':
389
				$entities = $getEntitiesIfSupported($businessLayer, 'findNotRecentPlay', $userId, $limit, $offset);
390
				break;
391
			case 'highest':
392
				$entities = $businessLayer->findAllRated($userId, $limit, $offset);
393
				break;
394
			default:
395
				throw new AmpacheException("Unsupported filter $filter", 400);
396
		}
397
398
		return $this->renderEntities($entities, $type);
399
	}
400
401
	/**
402
	 * @AmpacheAPI
403
	 */
404
	protected function artists(
405
			?string $filter, ?string $add, ?string $update,
406
			int $limit, int $offset=0, bool $exact=false) : array {
407
408
		$artists = $this->findEntities($this->artistBusinessLayer, $filter, $exact, $limit, $offset, $add, $update);
409
		return $this->renderArtists($artists);
410
	}
411
412
	/**
413
	 * @AmpacheAPI
414
	 */
415
	protected function artist(int $filter) : array {
416
		$userId = $this->session->getUserId();
417
		$artist = $this->artistBusinessLayer->find($filter, $userId);
418
		return $this->renderArtists([$artist]);
419
	}
420
421
	/**
422
	 * @AmpacheAPI
423
	 */
424
	protected function artist_albums(int $filter, int $limit, int $offset=0) : array {
425
		$userId = $this->session->getUserId();
426
		$albums = $this->albumBusinessLayer->findAllByArtist($filter, $userId, $limit, $offset);
427
		return $this->renderAlbums($albums);
428
	}
429
430
	/**
431
	 * @AmpacheAPI
432
	 */
433
	protected function artist_songs(int $filter, int $limit, int $offset=0, bool $top50=false) : array {
434
		$userId = $this->session->getUserId();
435
		if ($top50) {
436
			$tracks = $this->lastfmService->getTopTracks($filter, $userId, 50);
437
			$tracks = \array_slice($tracks, $offset, $limit);
438
		} else {
439
			$tracks = $this->trackBusinessLayer->findAllByArtist($filter, $userId, $limit, $offset);
440
		}
441
		return $this->renderSongs($tracks);
442
	}
443
444
	/**
445
	 * @AmpacheAPI
446
	 */
447
	protected function album_songs(int $filter, int $limit, int $offset=0) : array {
448
		$userId = $this->session->getUserId();
449
		$tracks = $this->trackBusinessLayer->findAllByAlbum($filter, $userId, null, $limit, $offset);
450
		return $this->renderSongs($tracks);
451
	}
452
453
	/**
454
	 * @AmpacheAPI
455
	 */
456
	protected function song(int $filter) : array {
457
		$userId = $this->session->getUserId();
458
		$track = $this->trackBusinessLayer->find($filter, $userId);
459
		$trackInArray = [$track];
460
		return $this->renderSongs($trackInArray);
461
	}
462
463
	/**
464
	 * @AmpacheAPI
465
	 */
466
	protected function songs(
467
			?string $filter, ?string $add, ?string $update,
468
			int $limit, int $offset=0, bool $exact=false) : array {
469
470
		$tracks = $this->findEntities($this->trackBusinessLayer, $filter, $exact, $limit, $offset, $add, $update);
471
		return $this->renderSongs($tracks);
472
	}
473
474
	/**
475
	 * @AmpacheAPI
476
	 */
477
	protected function search_songs(string $filter, int $limit, int $offset=0) : array {
478
		$userId = $this->session->getUserId();
479
		$tracks = $this->trackBusinessLayer->findAllByNameRecursive($filter, $userId, $limit, $offset);
480
		return $this->renderSongs($tracks);
481
	}
482
483
	/**
484
	 * @AmpacheAPI
485
	 */
486
	protected function albums(
487
			?string $filter, ?string $add, ?string $update,
488
			int $limit, int $offset=0, bool $exact=false) : array {
489
490
		$albums = $this->findEntities($this->albumBusinessLayer, $filter, $exact, $limit, $offset, $add, $update);
491
		return $this->renderAlbums($albums);
492
	}
493
494
	/**
495
	 * @AmpacheAPI
496
	 */
497
	protected function album(int $filter) : array {
498
		$userId = $this->session->getUserId();
499
		$album = $this->albumBusinessLayer->find($filter, $userId);
500
		return $this->renderAlbums([$album]);
501
	}
502
503
	/**
504
	 * @AmpacheAPI
505
	 */
506
	protected function get_similar(string $type, int $filter, int $limit, int $offset=0) : array {
507
		$userId = $this->session->getUserId();
508
		if ($type == 'artist') {
509
			$entities = $this->lastfmService->getSimilarArtists($filter, $userId);
510
		} elseif ($type == 'song') {
511
			$entities = $this->lastfmService->getSimilarTracks($filter, $userId);
512
		} else {
513
			throw new AmpacheException("Type '$type' is not supported", 400);
514
		}
515
		$entities = \array_slice($entities, $offset, $limit);
516
		return $this->renderEntitiesIndex($entities, $type);
517
	}
518
519
	/**
520
	 * @AmpacheAPI
521
	 */
522
	protected function playlists(
523
			?string $filter, ?string $add, ?string $update,
524
			int $limit, int $offset=0, bool $exact=false, int $hide_search=0) : array {
525
526
		$userId = $this->session->getUserId();
527
		$playlists = $this->findEntities($this->playlistBusinessLayer, $filter, $exact, $limit, $offset, $add, $update);
528
529
		// append "All tracks" if "seaches" are not forbidden, and not filtering by any criteria, and it is not off-limits
530
		$allTracksIndex = $this->playlistBusinessLayer->count($userId);
531
		if (!$hide_search && empty($filter) && empty($add) && empty($update)
532
				&& self::indexIsWithinOffsetAndLimit($allTracksIndex, $offset, $limit)) {
533
			$playlists[] = new AmpacheController_AllTracksPlaylist($userId, $this->trackBusinessLayer, $this->l10n);
534
		}
535
536
		return $this->renderPlaylists($playlists);
537
	}
538
539
	/**
540
	 * @AmpacheAPI
541
	 */
542
	protected function playlist(int $filter) : array {
543
		$userId = $this->session->getUserId();
544
		if ($filter == self::ALL_TRACKS_PLAYLIST_ID) {
545
			$playlist = new AmpacheController_AllTracksPlaylist($userId, $this->trackBusinessLayer, $this->l10n);
546
		} else {
547
			$playlist = $this->playlistBusinessLayer->find($filter, $userId);
548
		}
549
		return $this->renderPlaylists([$playlist]);
550
	}
551
552
	/**
553
	 * @AmpacheAPI
554
	 */
555
	protected function playlist_songs(int $filter, int $limit, int $offset=0) : array {
556
		$userId = $this->session->getUserId();
557
		if ($filter == self::ALL_TRACKS_PLAYLIST_ID) {
558
			$tracks = $this->trackBusinessLayer->findAll($userId, SortBy::Parent, $limit, $offset);
559
			foreach ($tracks as $index => &$track) {
560
				$track->setNumberOnPlaylist($index + 1);
561
			}
562
		} else {
563
			$tracks = $this->playlistBusinessLayer->getPlaylistTracks($filter, $userId, $limit, $offset);
564
		}
565
		return $this->renderSongs($tracks);
566
	}
567
568
	/**
569
	 * @AmpacheAPI
570
	 */
571
	protected function playlist_create(string $name) : array {
572
		$playlist = $this->playlistBusinessLayer->create($name, $this->session->getUserId());
573
		return $this->renderPlaylists([$playlist]);
574
	}
575
576
	/**
577
	 * @AmpacheAPI
578
	 *
579
	 * @param int $filter Playlist ID
580
	 * @param ?string $name New name for the playlist
581
	 * @param ?string $items Track IDs
582
	 * @param ?string $tracks 1-based indices of the tracks
583
	 */
584
	protected function playlist_edit(int $filter, ?string $name, ?string $items, ?string $tracks) : array {
585
		$edited = false;
586
		$userId = $this->session->getUserId();
587
		$playlist = $this->playlistBusinessLayer->find($filter, $userId);
588
589
		if (!empty($name)) {
590
			$playlist->setName($name);
591
			$edited = true;
592
		}
593
594
		$newTrackIds = Util::explode(',', $items);
595
		$newTrackOrdinals = Util::explode(',', $tracks);
596
597
		if (\count($newTrackIds) != \count($newTrackOrdinals)) {
598
			throw new AmpacheException("Arguments 'items' and 'tracks' must contain equal amount of elements", 400);
599
		} elseif (\count($newTrackIds) > 0) {
600
			$trackIds = $playlist->getTrackIdsAsArray();
601
602
			for ($i = 0, $count = \count($newTrackIds); $i < $count; ++$i) {
603
				$trackId = $newTrackIds[$i];
604
				if (!$this->trackBusinessLayer->exists($trackId, $userId)) {
605
					throw new AmpacheException("Invalid song ID $trackId", 404);
606
				}
607
				$trackIds[$newTrackOrdinals[$i]-1] = $trackId;
608
			}
609
610
			$playlist->setTrackIdsFromArray($trackIds);
611
			$edited = true;
612
		}
613
614
		if ($edited) {
615
			$this->playlistBusinessLayer->update($playlist);
616
			return ['success' => 'playlist changes saved'];
617
		} else {
618
			throw new AmpacheException('Nothing was changed', 400);
619
		}
620
	}
621
622
	/**
623
	 * @AmpacheAPI
624
	 */
625
	protected function playlist_delete(int $filter) : array {
626
		$this->playlistBusinessLayer->delete($filter, $this->session->getUserId());
627
		return ['success' => 'playlist deleted'];
628
	}
629
630
	/**
631
	 * @AmpacheAPI
632
	 */
633
	protected function playlist_add_song(int $filter, int $song, bool $check=false) : array {
634
		$userId = $this->session->getUserId();
635
		if (!$this->trackBusinessLayer->exists($song, $userId)) {
636
			throw new AmpacheException("Invalid song ID $song", 404);
637
		}
638
639
		$playlist = $this->playlistBusinessLayer->find($filter, $userId);
640
		$trackIds = $playlist->getTrackIdsAsArray();
641
642
		if ($check && \in_array($song, $trackIds)) {
643
			throw new AmpacheException("Can't add a duplicate item when check is enabled", 400);
644
		}
645
646
		$trackIds[] = $song;
647
		$playlist->setTrackIdsFromArray($trackIds);
648
		$this->playlistBusinessLayer->update($playlist);
649
		return ['success' => 'song added to playlist'];
650
	}
651
652
	/**
653
	 * @AmpacheAPI
654
	 *
655
	 * @param int $filter Playlist ID
656
	 * @param ?int $song Track ID
657
	 * @param ?int $track 1-based index of the track
658
	 * @param ?int $clear Value 1 erases all the songs from the playlist
659
	 */
660
	protected function playlist_remove_song(int $filter, ?int $song, ?int $track, ?int $clear) : array {
661
		$playlist = $this->playlistBusinessLayer->find($filter, $this->session->getUserId());
662
663
		if ($clear === 1) {
664
			$trackIds = [];
665
			$message = 'all songs removed from playlist';
666
		} elseif ($song !== null) {
667
			$trackIds = $playlist->getTrackIdsAsArray();
668
			if (!\in_array($song, $trackIds)) {
669
				throw new AmpacheException("Song $song not found in playlist", 404);
670
			}
671
			$trackIds = Util::arrayDiff($trackIds, [$song]);
672
			$message = 'song removed from playlist';
673
		} elseif ($track !== null) {
674
			$trackIds = $playlist->getTrackIdsAsArray();
675
			if ($track < 1 || $track > \count($trackIds)) {
676
				throw new AmpacheException("Track ordinal $track is out of bounds", 404);
677
			}
678
			unset($trackIds[$track-1]);
679
			$message = 'song removed from playlist';
680
		} else {
681
			throw new AmpacheException("One of the arguments 'clear', 'song', 'track' is required", 400);
682
		}
683
684
		$playlist->setTrackIdsFromArray($trackIds);
685
		$this->playlistBusinessLayer->update($playlist);
686
		return ['success' => $message];
687
	}
688
689
	/**
690
	 * @AmpacheAPI
691
	 */
692
	protected function playlist_generate(
693
			?string $filter, ?int $album, ?int $artist, ?int $flag,
694
			int $limit, int $offset=0, string $mode='random', string $format='song') : array {
695
696
		$tracks = $this->findEntities($this->trackBusinessLayer, $filter, false); // $limit and $offset are applied later
697
698
		// filter the found tracks according to the additional requirements
699
		if ($album !== null) {
700
			$tracks = \array_filter($tracks, function ($track) use ($album) {
701
				return ($track->getAlbumId() == $album);
702
			});
703
		}
704
		if ($artist !== null) {
705
			$tracks = \array_filter($tracks, function ($track) use ($artist) {
706
				return ($track->getArtistId() == $artist);
707
			});
708
		}
709
		if ($flag == 1) {
710
			$tracks = \array_filter($tracks, function ($track) {
711
				return ($track->getStarred() !== null);
712
			});
713
		}
714
		// After filtering, there may be "holes" between the array indices. Reindex the array.
715
		$tracks = \array_values($tracks);
716
717
		if ($mode == 'random') {
718
			$userId = $this->session->getUserId();
719
			$indices = $this->random->getIndices(\count($tracks), $offset, $limit, $userId, 'ampache_playlist_generate');
720
			$tracks = Util::arrayMultiGet($tracks, $indices);
721
		} else { // 'recent', 'forgotten', 'unplayed'
722
			throw new AmpacheException("Mode '$mode' is not supported", 400);
723
		}
724
725
		switch ($format) {
726
			case 'song':
727
				return $this->renderSongs($tracks);
728
			case 'index':
729
				return $this->renderSongsIndex($tracks);
730
			case 'id':
731
				return $this->renderEntityIds($tracks);
732
			default:
733
				throw new AmpacheException("Format '$format' is not supported", 400);
734
		}
735
	}
736
737
	/**
738
	 * @AmpacheAPI
739
	 */
740
	protected function podcasts(?string $filter, ?string $include, int $limit, int $offset=0, bool $exact=false) : array {
741
		$channels = $this->findEntities($this->podcastChannelBusinessLayer, $filter, $exact, $limit, $offset);
742
743
		if ($include === 'episodes') {
744
			$userId = $this->session->getUserId();
745
			$actuallyLimited = ($limit < $this->podcastChannelBusinessLayer->count($userId));
746
			$allChannelsIncluded = (!$filter && !$actuallyLimited && !$offset);
747
			$this->podcastService->injectEpisodes($channels, $userId, $allChannelsIncluded);
748
		}
749
750
		return $this->renderPodcastChannels($channels);
751
	}
752
753
	/**
754
	 * @AmpacheAPI
755
	 */
756
	protected function podcast(int $filter, ?string $include) : array {
757
		$userId = $this->session->getUserId();
758
		$channel = $this->podcastChannelBusinessLayer->find($filter, $userId);
759
760
		if ($include === 'episodes') {
761
			$channel->setEpisodes($this->podcastEpisodeBusinessLayer->findAllByChannel($filter, $userId));
762
		}
763
764
		return $this->renderPodcastChannels([$channel]);
765
	}
766
767
	/**
768
	 * @AmpacheAPI
769
	 */
770
	protected function podcast_create(string $url) : array {
771
		$userId = $this->session->getUserId();
772
		$result = $this->podcastService->subscribe($url, $userId);
773
774
		switch ($result['status']) {
775
			case PodcastService::STATUS_OK:
776
				return $this->renderPodcastChannels([$result['channel']]);
777
			case PodcastService::STATUS_INVALID_URL:
778
				throw new AmpacheException("Invalid URL $url", 400);
779
			case PodcastService::STATUS_INVALID_RSS:
780
				throw new AmpacheException("The document at URL $url is not a valid podcast RSS feed", 400);
781
			case PodcastService::STATUS_ALREADY_EXISTS:
782
				throw new AmpacheException('User already has this podcast channel subscribed', 400);
783
			default:
784
				throw new AmpacheException("Unexpected status code {$result['status']}", 400);
785
		}
786
	}
787
788
	/**
789
	 * @AmpacheAPI
790
	 */
791
	protected function podcast_delete(int $filter) : array {
792
		$userId = $this->session->getUserId();
793
		$status = $this->podcastService->unsubscribe($filter, $userId);
794
795
		switch ($status) {
796
			case PodcastService::STATUS_OK:
797
				return ['success' => 'podcast deleted'];
798
			case PodcastService::STATUS_NOT_FOUND:
799
				throw new AmpacheException('Channel to be deleted not found', 404);
800
			default:
801
				throw new AmpacheException("Unexpected status code $status", 400);
802
		}
803
	}
804
805
	/**
806
	 * @AmpacheAPI
807
	 */
808
	protected function podcast_episodes(int $filter, int $limit, int $offset=0) : array {
809
		$userId = $this->session->getUserId();
810
		$episodes = $this->podcastEpisodeBusinessLayer->findAllByChannel($filter, $userId, $limit, $offset);
811
		return $this->renderPodcastEpisodes($episodes);
812
	}
813
814
	/**
815
	 * @AmpacheAPI
816
	 */
817
	protected function podcast_episode(int $filter) : array {
818
		$userId = $this->session->getUserId();
819
		$episode = $this->podcastEpisodeBusinessLayer->find($filter, $userId);
820
		return $this->renderPodcastEpisodes([$episode]);
821
	}
822
823
	/**
824
	 * @AmpacheAPI
825
	 */
826
	protected function update_podcast(int $id) : array {
827
		$userId = $this->session->getUserId();
828
		$result = $this->podcastService->updateChannel($id, $userId);
829
830
		switch ($result['status']) {
831
			case PodcastService::STATUS_OK:
832
				$message = $result['updated'] ? 'channel was updated from the source' : 'no changes found';
833
				return ['success' => $message];
834
			case PodcastService::STATUS_NOT_FOUND:
835
				throw new AmpacheException('Channel to be updated not found', 404);
836
			case PodcastService::STATUS_INVALID_URL:
837
				throw new AmpacheException('failed to read from the channel URL', 400);
838
			case PodcastService::STATUS_INVALID_RSS:
839
				throw new AmpacheException('the document at the channel URL is not a valid podcast RSS feed', 400);
840
			default:
841
				throw new AmpacheException("Unexpected status code {$result['status']}", 400);
842
		}
843
	}
844
845
	/**
846
	 * @AmpacheAPI
847
	 */
848
	protected function live_streams(?string $filter, int $limit, int $offset=0, bool $exact=false) : array {
849
		$stations = $this->findEntities($this->radioStationBusinessLayer, $filter, $exact, $limit, $offset);
850
		return $this->renderLiveStreams($stations);
851
	}
852
853
	/**
854
	 * @AmpacheAPI
855
	 */
856
	protected function live_stream(int $filter) : array {
857
		$station = $this->radioStationBusinessLayer->find($filter, $this->session->getUserId());
858
		return $this->renderLiveStreams([$station]);
859
	}
860
861
	/**
862
	 * @AmpacheAPI
863
	 */
864
	protected function live_stream_create(string $name, string $url, ?string $site_url) : array {
865
		$station = $this->radioStationBusinessLayer->create($this->session->getUserId(), $name, $url, $site_url);
866
		return $this->renderLiveStreams([$station]);
867
	}
868
869
	/**
870
	 * @AmpacheAPI
871
	 */
872
	protected function live_stream_delete(int $filter) : array {
873
		$this->radioStationBusinessLayer->delete($filter, $this->session->getUserId());
874
		return ['success' => "Deleted live stream: $filter"];
875
	}
876
877
	/**
878
	 * @AmpacheAPI
879
	 */
880
	protected function live_stream_edit(int $filter, ?string $name, ?string $url, ?string $site_url) : array {
881
		$station = $this->radioStationBusinessLayer->find($filter, $this->session->getUserId());
882
883
		if ($name !== null) {
884
			$station->setName($name);
885
		}
886
		if ($url !== null) {
887
			$station->setStreamUrl($url);
888
		}
889
		if ($site_url !== null) {
890
			$station->setHomeUrl($site_url);
891
		}
892
		$station = $this->radioStationBusinessLayer->update($station);
893
894
		return $this->renderLiveStreams([$station]);
895
	}
896
897
	/**
898
	 * @AmpacheAPI
899
	 */
900
	protected function tags(?string $filter, int $limit, int $offset=0, bool $exact=false) : array {
901
		$genres = $this->findEntities($this->genreBusinessLayer, $filter, $exact, $limit, $offset);
902
		return $this->renderTags($genres);
903
	}
904
905
	/**
906
	 * @AmpacheAPI
907
	 */
908
	protected function tag(int $filter) : array {
909
		$userId = $this->session->getUserId();
910
		$genre = $this->genreBusinessLayer->find($filter, $userId);
911
		return $this->renderTags([$genre]);
912
	}
913
914
	/**
915
	 * @AmpacheAPI
916
	 */
917
	protected function tag_artists(int $filter, int $limit, int $offset=0) : array {
918
		$userId = $this->session->getUserId();
919
		$artists = $this->artistBusinessLayer->findAllByGenre($filter, $userId, $limit, $offset);
920
		return $this->renderArtists($artists);
921
	}
922
923
	/**
924
	 * @AmpacheAPI
925
	 */
926
	protected function tag_albums(int $filter, int $limit, int $offset=0) : array {
927
		$userId = $this->session->getUserId();
928
		$albums = $this->albumBusinessLayer->findAllByGenre($filter, $userId, $limit, $offset);
929
		return $this->renderAlbums($albums);
930
	}
931
932
	/**
933
	 * @AmpacheAPI
934
	 */
935
	protected function tag_songs(int $filter, int $limit, int $offset=0) : array {
936
		$userId = $this->session->getUserId();
937
		$tracks = $this->trackBusinessLayer->findAllByGenre($filter, $userId, $limit, $offset);
938
		return $this->renderSongs($tracks);
939
	}
940
941
	/**
942
	 * @AmpacheAPI
943
	 */
944
	protected function genres(?string $filter, int $limit, int $offset=0, bool $exact=false) : array {
945
		$genres = $this->findEntities($this->genreBusinessLayer, $filter, $exact, $limit, $offset);
946
		return $this->renderGenres($genres);
947
	}
948
949
	/**
950
	 * @AmpacheAPI
951
	 */
952
	protected function genre(int $filter) : array {
953
		$userId = $this->session->getUserId();
954
		$genre = $this->genreBusinessLayer->find($filter, $userId);
955
		return $this->renderGenres([$genre]);
956
	}
957
958
	/**
959
	 * @AmpacheAPI
960
	 */
961
	protected function genre_artists(?int $filter, int $limit, int $offset=0) : array {
962
		if ($filter === null) {
963
			return $this->artists(null, null, null, $limit, $offset);
964
		} else {
965
			return $this->tag_artists($filter, $limit, $offset);
966
		}
967
	}
968
969
	/**
970
	 * @AmpacheAPI
971
	 */
972
	protected function genre_albums(?int $filter, int $limit, int $offset=0) : array {
973
		if ($filter === null) {
974
			return $this->albums(null, null, null, $limit, $offset);
975
		} else {
976
			return $this->tag_albums($filter, $limit, $offset);
977
		}
978
	}
979
980
	/**
981
	 * @AmpacheAPI
982
	 */
983
	protected function genre_songs(?int $filter, int $limit, int $offset=0) : array {
984
		if ($filter === null) {
985
			return $this->songs(null, null, null, $limit, $offset);
986
		} else {
987
			return $this->tag_songs($filter, $limit, $offset);
988
		}
989
	}
990
991
	/**
992
	 * @AmpacheAPI
993
	 */
994
	protected function bookmarks() : array {
995
		$bookmarks = $this->bookmarkBusinessLayer->findAll($this->session->getUserId());
996
		return $this->renderBookmarks($bookmarks);
997
	}
998
999
	/**
1000
	 * @AmpacheAPI
1001
	 */
1002
	protected function get_bookmark(int $filter, string $type) : array {
1003
		$entryType = self::mapBookmarkType($type);
1004
		$bookmark = $this->bookmarkBusinessLayer->findByEntry($entryType, $filter, $this->session->getUserId());
1005
		return $this->renderBookmarks([$bookmark]);
1006
	}
1007
1008
	/**
1009
	 * @AmpacheAPI
1010
	 */
1011
	protected function bookmark_create(int $filter, string $type, int $position, string $client='AmpacheAPI') : array {
1012
		// Note: the optional argument 'date' is not supported and is disregarded
1013
		$entryType = self::mapBookmarkType($type);
1014
		$position *= 1000; // seconds to milliseconds
1015
		$bookmark = $this->bookmarkBusinessLayer->addOrUpdate($this->session->getUserId(), $entryType, $filter, $position, $client);
1016
		return $this->renderBookmarks([$bookmark]);
1017
	}
1018
1019
	/**
1020
	 * @AmpacheAPI
1021
	 */
1022
	protected function bookmark_edit(int $filter, string $type, int $position, ?string $client) : array {
1023
		// Note: the optional argument 'date' is not supported and is disregarded
1024
		$entryType = self::mapBookmarkType($type);
1025
		$bookmark = $this->bookmarkBusinessLayer->findByEntry($entryType, $filter, $this->session->getUserId());
1026
		$bookmark->setPosition($position * 1000); // seconds to milliseconds
1027
		if ($client !== null) {
1028
			$bookmark->setComment($client);
1029
		}
1030
		$bookmark = $this->bookmarkBusinessLayer->update($bookmark);
1031
		return $this->renderBookmarks([$bookmark]);
1032
	}
1033
1034
	/**
1035
	 * @AmpacheAPI
1036
	 */
1037
	protected function bookmark_delete(int $filter, string $type) : array {
1038
		$entryType = self::mapBookmarkType($type);
1039
		$bookmark = $this->bookmarkBusinessLayer->findByEntry($entryType, $filter, $this->session->getUserId());
1040
		$this->bookmarkBusinessLayer->delete($bookmark->getId(), $bookmark->getUserId());
1041
		return ['success' => "Deleted Bookmark: $type $filter"];
1042
	}
1043
1044
	/**
1045
	 * @AmpacheAPI
1046
	 */
1047
	protected function advanced_search(string $type, string $operator, int $limit, int $offset=0, bool $random=false) : array {
1048
		// get all the rule parameters as passed on the HTTP call
1049
		$rules = self::advSearchGetRuleParams($this->request->getParams());
1050
1051
		// apply some conversions on the rules
1052
		foreach ($rules as &$rule) {
1053
			$rule['rule'] = self::advSearchResolveRuleAlias($rule['rule']);
1054
			$rule['operator'] = self::advSearchInterpretOperator($rule['operator'], $rule['rule']);
1055
			$rule['input'] = self::advSearchConvertInput($rule['input'], $rule['rule']);
1056
		}
1057
1058
		try {
1059
			$businessLayer = $this->getBusinessLayer($type);
1060
			$userId = $this->session->getUserId();
1061
			if ($random) {
1062
				// in case the random order is requested, the limit/offset handling happens after the DB query
1063
				$entities = $businessLayer->findAllAdvanced($operator, $rules, $userId);
1064
				$indices = $this->random->getIndices(\count($entities), $offset, $limit, $userId, 'ampache_adv_search_'.$type);
1065
				$entities = Util::arrayMultiGet($entities, $indices);
1066
			} else {
1067
				$entities = $businessLayer->findAllAdvanced($operator, $rules, $userId, $limit, $offset);
1068
			}
1069
		} catch (BusinessLayerException $e) {
1070
			throw new AmpacheException($e->getMessage(), 400);
1071
		}
1072
		
1073
		return $this->renderEntities($entities, $type);
1074
	}
1075
1076
	/**
1077
	 * @AmpacheAPI
1078
	 */
1079
	protected function flag(string $type, int $id, bool $flag) : array {
1080
		if (!\in_array($type, ['song', 'album', 'artist', 'podcast', 'podcast_episode', 'playlist'])) {
1081
			throw new AmpacheException("Unsupported type $type", 400);
1082
		}
1083
1084
		$userId = $this->session->getUserId();
1085
		$businessLayer = $this->getBusinessLayer($type);
1086
		if ($flag) {
1087
			$modifiedCount = $businessLayer->setStarred([$id], $userId);
1088
			$message = "flag ADDED to $type $id";
1089
		} else {
1090
			$modifiedCount = $businessLayer->unsetStarred([$id], $userId);
1091
			$message = "flag REMOVED from $type $id";
1092
		}
1093
1094
		if ($modifiedCount > 0) {
1095
			return ['success' => $message];
1096
		} else {
1097
			throw new AmpacheException("The $type $id was not found", 404);
1098
		}
1099
	}
1100
1101
	/**
1102
	 * @AmpacheAPI
1103
	 */
1104
	protected function rate(string $type, int $id, int $rating) : array {
1105
		$rating = Util::limit($rating, 0, 5);
1106
		$userId = $this->session->getUserId();
1107
		$businessLayer = $this->getBusinessLayer($type);
1108
		$entity = $businessLayer->find($id, $userId);
1109
		if (\property_exists($entity, 'rating')) {
1110
			$entity->setRating($rating);
0 ignored issues
show
Bug introduced by
The method setRating() does not exist on OCA\Music\Db\Bookmark. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

1110
			$entity->/** @scrutinizer ignore-call */ 
1111
            setRating($rating);
Loading history...
Bug introduced by
The method setRating() does not exist on OCA\Music\Db\Genre. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

1110
			$entity->/** @scrutinizer ignore-call */ 
1111
            setRating($rating);
Loading history...
Bug introduced by
The method setRating() does not exist on OCA\Music\Db\RadioStation. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

1110
			$entity->/** @scrutinizer ignore-call */ 
1111
            setRating($rating);
Loading history...
1111
			$businessLayer->update($entity);
1112
		} else {
1113
			throw new AmpacheException("Unsupported type $type", 400);
1114
		}
1115
1116
		return ['success' => "rating set to $rating for $type $id"];
1117
	}
1118
1119
	/**
1120
	 * @AmpacheAPI
1121
	 */
1122
	protected function record_play(int $id, ?int $date) : array {
1123
		$timeOfPlay = ($date === null) ? null : new \DateTime('@' . $date);
1124
		$this->trackBusinessLayer->recordTrackPlayed($id, $this->session->getUserId(), $timeOfPlay);
1125
		return ['success' => 'play recorded'];
1126
	}
1127
1128
	/**
1129
	 * @AmpacheAPI
1130
	 */
1131
	protected function download(int $id, string $type='song') : Response {
1132
		// request param `format` is ignored
1133
		$userId = $this->session->getUserId();
1134
1135
		if ($type === 'song') {
1136
			try {
1137
				$track = $this->trackBusinessLayer->find($id, $userId);
1138
			} catch (BusinessLayerException $e) {
1139
				return new ErrorResponse(Http::STATUS_NOT_FOUND, $e->getMessage());
1140
			}
1141
1142
			$file = $this->librarySettings->getFolder($userId)->getById($track->getFileId())[0] ?? null;
1143
1144
			if ($file instanceof \OCP\Files\File) {
1145
				return new FileStreamResponse($file);
1146
			} else {
1147
				return new ErrorResponse(Http::STATUS_NOT_FOUND);
1148
			}
1149
		} elseif ($type === 'podcast' || $type === 'podcast_episode') { // there's a difference between APIv4 and APIv5
1150
			$episode = $this->podcastEpisodeBusinessLayer->find($id, $userId);
1151
			return new RedirectResponse($episode->getStreamUrl());
1152
		} elseif ($type === 'playlist') {
1153
			$songIds = ($id === self::ALL_TRACKS_PLAYLIST_ID)
1154
				? $this->trackBusinessLayer->findAllIds($userId)
1155
				: $this->playlistBusinessLayer->find($id, $userId)->getTrackIdsAsArray();
1156
			if (empty($songIds)) {
1157
				throw new AmpacheException("The playlist $id is empty", 404);
1158
			} else {
1159
				return $this->download(Random::pickItem($songIds));
0 ignored issues
show
Bug introduced by
It seems like OCA\Music\Utility\Random::pickItem($songIds) can also be of type null; however, parameter $id of OCA\Music\Controller\AmpacheController::download() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1159
				return $this->download(/** @scrutinizer ignore-type */ Random::pickItem($songIds));
Loading history...
1160
			}
1161
		} else {
1162
			throw new AmpacheException("Unsupported type '$type'", 400);
1163
		}
1164
	}
1165
1166
	/**
1167
	 * @AmpacheAPI
1168
	 */
1169
	protected function stream(int $id, ?int $offset, string $type='song') : Response {
1170
		// request params `bitrate`, `format`, and `length` are ignored
1171
1172
		// This is just a dummy implementation. We don't support transcoding or streaming
1173
		// from a time offset.
1174
		// All the other unsupported arguments are just ignored, but a request with an offset
1175
		// is responded with an error. This is becuase the client would probably work in an
1176
		// unexpected way if it thinks it's streaming from offset but actually it is streaming
1177
		// from the beginning of the file. Returning an error gives the client a chance to fallback
1178
		// to other methods of seeking.
1179
		if ($offset !== null) {
1180
			throw new AmpacheException('Streaming with time offset is not supported', 400);
1181
		}
1182
1183
		return $this->download($id, $type);
1184
	}
1185
1186
	/**
1187
	 * @AmpacheAPI
1188
	 */
1189
	protected function get_art(string $type, int $id) : Response {
1190
		if (!\in_array($type, ['song', 'album', 'artist', 'podcast', 'playlist'])) {
1191
			throw new AmpacheException("Unsupported type $type", 400);
1192
		}
1193
1194
		if ($type === 'song') {
1195
			// map song to its parent album
1196
			$id = $this->trackBusinessLayer->find($id, $this->session->getUserId())->getAlbumId();
1197
			$type = 'album';
1198
		}
1199
1200
		return $this->getCover($id, $this->getBusinessLayer($type));
1201
	}
1202
1203
	/********************
1204
	 * Helper functions *
1205
	 ********************/
1206
1207
	private function getBusinessLayer(string $type) : BusinessLayer {
1208
		switch ($type) {
1209
			case 'song':			return $this->trackBusinessLayer;
1210
			case 'album':			return $this->albumBusinessLayer;
1211
			case 'artist':			return $this->artistBusinessLayer;
1212
			case 'playlist':		return $this->playlistBusinessLayer;
1213
			case 'podcast':			return $this->podcastChannelBusinessLayer;
1214
			case 'podcast_episode':	return $this->podcastEpisodeBusinessLayer;
1215
			case 'live_stream':		return $this->radioStationBusinessLayer;
1216
			case 'tag':				return $this->genreBusinessLayer;
1217
			case 'genre':			return $this->genreBusinessLayer;
1218
			case 'bookmark':		return $this->bookmarkBusinessLayer;
1219
			default:				throw new AmpacheException("Unsupported type $type", 400);
1220
		}
1221
	}
1222
1223
	private function renderEntities(array $entities, string $type) : array {
1224
		switch ($type) {
1225
			case 'song':			return $this->renderSongs($entities);
1226
			case 'album':			return $this->renderAlbums($entities);
1227
			case 'artist':			return $this->renderArtists($entities);
1228
			case 'playlist':		return $this->renderPlaylists($entities);
1229
			case 'podcast':			return $this->renderPodcastChannels($entities);
1230
			case 'podcast_episode':	return $this->renderPodcastEpisodes($entities);
1231
			case 'live_stream':		return $this->renderLiveStreams($entities);
1232
			case 'tag':				return $this->renderTags($entities);
1233
			case 'genre':			return $this->renderGenres($entities);
1234
			case 'bookmark':		return $this->renderBookmarks($entities);
1235
			default:				throw new AmpacheException("Unsupported type $type", 400);
1236
		}
1237
	}
1238
1239
	private function renderEntitiesIndex($entities, $type) : array {
1240
		switch ($type) {
1241
			case 'song':			return $this->renderSongsIndex($entities);
1242
			case 'album':			return $this->renderAlbumsIndex($entities);
1243
			case 'artist':			return $this->renderArtistsIndex($entities);
1244
			case 'playlist':		return $this->renderPlaylistsIndex($entities);
1245
			case 'podcast':			return $this->renderPodcastChannelsIndex($entities);
1246
			case 'podcast_episode':	return $this->renderPodcastEpisodesIndex($entities);
1247
			case 'live_stream':		return $this->renderLiveStreamsIndex($entities);
1248
			case 'tag':				return $this->renderTags($entities); // not part of the API spec
1249
			case 'genre':			return $this->renderGenres($entities); // not part of the API spec
1250
			case 'bookmark':		return $this->renderBookmarks($entities); // not part of the API spec
1251
			default:				throw new AmpacheException("Unsupported type $type", 400);
1252
		}
1253
	}
1254
1255
	private static function mapBookmarkType(string $ampacheType) : int {
1256
		switch ($ampacheType) {
1257
			case 'song':			return Bookmark::TYPE_TRACK;
1258
			case 'podcast_episode':	return Bookmark::TYPE_PODCAST_EPISODE;
1259
			default:				throw new AmpacheException("Unsupported type $ampacheType", 400);
1260
		}
1261
	}
1262
1263
	private static function advSearchResolveRuleAlias(string $rule) : string {
1264
		switch ($rule) {
1265
			case 'name':					return 'title';
1266
			case 'song_title':				return 'song';
1267
			case 'album_title':				return 'album';
1268
			case 'artist_title':			return 'artist';
1269
			case 'podcast_title':			return 'podcast';
1270
			case 'podcast_episode_title':	return 'podcast_episode';
1271
			case 'album_artist_title':		return 'album_artist';
1272
			case 'song_artist_title':		return 'song_artist';
1273
			case 'tag':						return 'genre';
1274
			case 'song_tag':				return 'song_genre';
1275
			case 'album_tag':				return 'album_genre';
1276
			case 'artist_tag':				return 'artist_genre';
1277
			case 'no_tag':					return 'no_genre';
1278
			default:						return $rule;
1279
		}
1280
	}
1281
1282
	private static function advSearchGetRuleParams(array $urlParams) : array {
1283
		$rules = [];
1284
1285
		// read and organize the rule parameters
1286
		foreach ($urlParams as $key => $value) {
1287
			$parts = \explode('_', $key, 3);
1288
			if ($parts[0] == 'rule' && \count($parts) > 1) {
1289
				if (\count($parts) == 2) {
1290
					$rules[$parts[1]]['rule'] = $value;
1291
				} elseif ($parts[2] == 'operator') {
1292
					$rules[$parts[1]]['operator'] = (int)$value;
1293
				} elseif ($parts[2] == 'input') {
1294
					$rules[$parts[1]]['input'] = $value;
1295
				}
1296
			}
1297
		}
1298
1299
		// validate the rule parameters
1300
		if (\count($rules) === 0) {
1301
			throw new AmpacheException('At least one rule must be given', 400);
1302
		}
1303
		foreach ($rules as $rule) {
1304
			if (\count($rule) != 3) {
1305
				throw new AmpacheException('All rules must be given as triplet "rule_N", "rule_N_operator", "rule_N_input"', 400);
1306
			}
1307
		}
1308
1309
		return $rules;
1310
	}
1311
1312
	// NOTE: alias rule names should be resolved to their base form before calling this
1313
	private static function advSearchInterpretOperator(int $rule_operator, string $rule) : string {
1314
		// Operator mapping is different for text, numeric, date, boolean, and day rules
1315
1316
		$textRules = [
1317
			'anywhere', 'title', 'song', 'album', 'artist', 'podcast', 'podcast_episode', 'album_artist', 'song_artist',
1318
			'favorite', 'favorite_album', 'favorite_artist', 'genre', 'song_genre', 'album_genre', 'artist_genre',
1319
			'playlist_name', 'type', 'file', 'mbid', 'mbid_album', 'mbid_artist', 'mbid_song'
1320
		];
1321
		// text but no support planned: 'composer', 'summary', 'placeformed', 'release_type', 'release_status', 'barcode',
1322
		// 'catalog_number', 'label', 'comment', 'lyrics', 'username', 'category'
1323
1324
		$numericRules = [
1325
			'track', 'year', 'original_year', 'myrating', 'rating', 'songrating', 'albumrating', 'artistrating',
1326
			'played_times', 'album_count', 'song_count', 'time'
1327
		];
1328
		// numeric but no support planned: 'yearformed', 'skipped_times', 'play_skip_ratio', 'image_height', 'image_width'
1329
1330
		$numericLimitRules = ['recent_played', 'recent_added', 'recent_updated'];
1331
1332
		$dateOrDayRules = ['added', 'updated', 'pubdate', 'last_play'];
1333
1334
		$booleanRules = [
1335
			'played', 'myplayed', 'myplayedalbum', 'myplayedartist', 'has_image', 'no_genre',
1336
			'my_flagged', 'my_flagged_album', 'my_flagged_artist'
1337
		];
1338
		// boolean but no support planned: 'smartplaylist', 'possible_duplicate', 'possible_duplicate_album'
1339
1340
		$booleanNumericRules = ['playlist'];
1341
		// boolean numeric but no support planned: 'license', 'state', 'catalog'
1342
1343
		if (\in_array($rule, $textRules)) {
1344
			switch ($rule_operator) {
1345
				case 0: return 'contain';		// contains
1346
				case 1: return 'notcontain';	// does not contain;
1347
				case 2: return 'start';			// starts with
1348
				case 3: return 'end';			// ends with;
1349
				case 4: return 'is';			// is
1350
				case 5: return 'isnot';			// is not
1351
				case 6: return 'sounds';		// sounds like
1352
				case 7: return 'notsounds';		// does not sound like
1353
				case 8: return 'regexp';		// matches regex
1354
				case 9: return 'notregexp';		// does not match regex
1355
				default: throw new AmpacheException("Search operator '$rule_operator' not supported for 'text' type rules", 400);
1356
			}
1357
		} elseif (\in_array($rule, $numericRules)) {
1358
			switch ($rule_operator) {
1359
				case 0: return '>=';
1360
				case 1: return '<=';
1361
				case 2: return '=';
1362
				case 3: return '!=';
1363
				case 4: return '>';
1364
				case 5: return '<';
1365
				default: throw new AmpacheException("Search operator '$rule_operator' not supported for 'numeric' type rules", 400);
1366
			}
1367
		} elseif (\in_array($rule, $numericLimitRules)) {
1368
			return 'limit';
1369
		} elseif (\in_array($rule, $dateOrDayRules)) {
1370
			switch ($rule_operator) {
1371
				case 0: return '<';
1372
				case 1: return '>';
1373
				default: throw new AmpacheException("Search operator '$rule_operator' not supported for 'date' or 'day' type rules", 400);
1374
			}
1375
		} elseif (\in_array($rule, $booleanRules)) {
1376
			switch ($rule_operator) {
1377
				case 0: return 'true';
1378
				case 1: return 'false';
1379
				default: throw new AmpacheException("Search operator '$rule_operator' not supported for 'boolean' type rules", 400);
1380
			}
1381
		} elseif (\in_array($rule, $booleanNumericRules)) {
1382
			switch ($rule_operator) {
1383
				case 0: return 'equal';
1384
				case 1: return 'ne';
1385
				default: throw new AmpacheException("Search operator '$rule_operator' not supported for 'boolean numeric' type rules", 400);
1386
			}
1387
		} else {
1388
			throw new AmpacheException("Search rule '$rule' not supported", 400);
1389
		}
1390
	}
1391
1392
	private static function advSearchConvertInput(string $input, string $rule) {
1393
		switch ($rule) {
1394
			case 'last_play':
1395
				// days diff to ISO date
1396
				$date = new \DateTime("$input days ago");
1397
				return $date->format(BaseMapper::SQL_DATE_FORMAT);
1398
			case 'time':
1399
				// minutes to seconds
1400
				return (string)(int)((float)$input * 60);
1401
			default:
1402
				return $input;
1403
		}
1404
	}
1405
1406
	private function getAppNameAndVersion() : string {
1407
		$vendor = 'owncloud/nextcloud'; // this should get overridden by the next 'include'
1408
		include \OC::$SERVERROOT . '/version.php';
1409
1410
		$appVersion = AppInfo::getVersion();
1411
1412
		return "$vendor {$this->appName} $appVersion";
1413
	}
1414
1415
	private function getCover(int $entityId, BusinessLayer $businessLayer) : Response {
1416
		$userId = $this->session->getUserId();
1417
		$userFolder = $this->librarySettings->getFolder($userId);
1418
1419
		try {
1420
			$entity = $businessLayer->find($entityId, $userId);
1421
			$coverData = $this->coverHelper->getCover($entity, $userId, $userFolder);
1422
			if ($coverData !== null) {
1423
				return new FileResponse($coverData);
1424
			}
1425
		} catch (BusinessLayerException $e) {
1426
			return new ErrorResponse(Http::STATUS_NOT_FOUND, 'entity not found');
1427
		}
1428
1429
		return new ErrorResponse(Http::STATUS_NOT_FOUND, 'entity has no cover');
1430
	}
1431
1432
	private function findEntities(
1433
			BusinessLayer $businessLayer, ?string $filter, bool $exact, ?int $limit=null, ?int $offset=null, ?string $add=null, ?string $update=null) : array {
1434
1435
		$userId = $this->session->getUserId();
1436
1437
		// It's not documented, but Ampache supports also specifying date range on `add` and `update` parameters
1438
		// by using '/' as separator. If there is no such separator, then the value is used as a lower limit.
1439
		$add = Util::explode('/', $add);
1440
		$update = Util::explode('/', $update);
1441
		$addMin = $add[0] ?? null;
1442
		$addMax = $add[1] ?? null;
1443
		$updateMin = $update[0] ?? null;
1444
		$updateMax = $update[1] ?? null;
1445
1446
		if ($filter) {
1447
			$matchMode = $exact ? MatchMode::Exact : MatchMode::Substring;
1448
			return $businessLayer->findAllByName($filter, $userId, $matchMode, $limit, $offset, $addMin, $addMax, $updateMin, $updateMax);
1449
		} else {
1450
			return $businessLayer->findAll($userId, SortBy::Name, $limit, $offset, $addMin, $addMax, $updateMin, $updateMax);
1451
		}
1452
	}
1453
1454
	private function createAmpacheActionUrl(string $action, int $id, ?string $type=null) : string {
1455
		$api = $this->jsonMode ? 'music.ampache.jsonApi' : 'music.ampache.xmlApi';
1456
		$auth = $this->session->getToken();
1457
		return $this->urlGenerator->linkToRouteAbsolute($api)
1458
				. "?action=$action&id=$id&auth=$auth"
1459
				. (!empty($type) ? "&type=$type" : '');
1460
	}
1461
1462
	private function createCoverUrl(Entity $entity) : string {
1463
		if ($entity instanceof Album) {
1464
			$type = 'album';
1465
		} elseif ($entity instanceof Artist) {
1466
			$type = 'artist';
1467
		} elseif ($entity instanceof Playlist) {
1468
			$type = 'playlist';
1469
		} else {
1470
			throw new AmpacheException('unexpeted entity type for cover image', 500);
1471
		}
1472
1473
		if ($type === 'playlist' || $entity->getCoverFileId()) {
0 ignored issues
show
Bug introduced by
The method getCoverFileId() does not exist on OCA\Music\Db\Entity. Since you implemented __call, consider adding a @method annotation. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-call  annotation

1473
		if ($type === 'playlist' || $entity->/** @scrutinizer ignore-call */ getCoverFileId()) {
Loading history...
1474
			$id = $entity->getId();
1475
			$token = $this->imageService->getToken($type, $id, $this->session->getAmpacheUserId());
1476
			return $this->urlGenerator->linkToRouteAbsolute('music.ampacheImage.image') . "?object_type=$type&object_id=$id&token=$token";
1477
		} else {
1478
			return '';
1479
		}
1480
	}
1481
1482
	private static function indexIsWithinOffsetAndLimit(int $index, ?int $offset, ?int $limit) : bool {
1483
		$offset = \intval($offset); // missing offset is interpreted as 0-offset
1484
		return ($limit === null) || ($index >= $offset && $index < $offset + $limit);
1485
	}
1486
1487
	private function prefixAndBaseName(?string $name) : array {
1488
		$parts = ['prefix' => null, 'basename' => $name];
1489
1490
		if ($name !== null) {
1491
			foreach ($this->namePrefixes as $prefix) {
1492
				if (Util::startsWith($name, $prefix . ' ', /*ignoreCase=*/true)) {
1493
					$parts['prefix'] = $prefix;
1494
					$parts['basename'] = \substr($name, \strlen($prefix) + 1);
1495
					break;
1496
				}
1497
			}
1498
		}
1499
1500
		return $parts;
1501
	}
1502
1503
	private function renderAlbumOrArtistRef(int $id, string $name) : array {
1504
		if ($this->apiMajorVersion() > 5) {
1505
			return [
1506
				'id' => (string)$id,
1507
				'name' => $name,
1508
			] + $this->prefixAndBaseName($name);
1509
		} else {
1510
			return [
1511
				'id' => (string)$id,
1512
				'value' => $name
1513
			];
1514
		}
1515
	}
1516
1517
	/**
1518
	 * @param Artist[] $artists
1519
	 */
1520
	private function renderArtists(array $artists) : array {
1521
		$userId = $this->session->getUserId();
1522
		$genreMap = Util::createIdLookupTable($this->genreBusinessLayer->findAll($userId));
1523
		$genreKey = $this->genreKey();
1524
1525
		return [
1526
			'artist' => \array_map(function (Artist $artist) use ($userId, $genreMap, $genreKey) {
1527
				$albumCount = $this->albumBusinessLayer->countByArtist($artist->getId());
1528
				$songCount = $this->trackBusinessLayer->countByArtist($artist->getId());
1529
				$name = $artist->getNameString($this->l10n);
1530
				$nameParts = $this->prefixAndBaseName($name);
1531
				return [
1532
					'id' => (string)$artist->getId(),
1533
					'name' => $name,
1534
					'prefix' => $nameParts['prefix'],
1535
					'basename' => $nameParts['basename'],
1536
					'albums' => $albumCount, // TODO: this should contain objects if requested; in API5+, this never contains the count
1537
					'albumcount' => $albumCount,
1538
					'songs' => $songCount, // TODO: this should contain objects if requested; in API5+, this never contains the count
1539
					'songcount' => $songCount,
1540
					'time' => $this->trackBusinessLayer->totalDurationByArtist($artist->getId()),
1541
					'art' => $this->createCoverUrl($artist),
1542
					'rating' => $artist->getRating() ?? 0,
1543
					'preciserating' => $artist->getRating() ?? 0,
1544
					'flag' => !empty($artist->getStarred()),
1545
					$genreKey => \array_map(function ($genreId) use ($genreMap) {
1546
						return [
1547
							'id' => (string)$genreId,
1548
							'value' => $genreMap[$genreId]->getNameString($this->l10n),
1549
							'count' => 1
1550
						];
1551
					}, $this->trackBusinessLayer->getGenresByArtistId($artist->getId(), $userId))
1552
				];
1553
			}, $artists)
1554
		];
1555
	}
1556
1557
	/**
1558
	 * @param Album[] $albums
1559
	 */
1560
	private function renderAlbums(array $albums) : array {
1561
		$genreKey = $this->genreKey();
1562
		// In APIv6 JSON format, there is a new property `artists` with an array value
1563
		$includeArtists = ($this->jsonMode && $this->apiMajorVersion() > 5);
1564
1565
		return [
1566
			'album' => \array_map(function (Album $album) use ($genreKey, $includeArtists) {
1567
				$songCount = $this->trackBusinessLayer->countByAlbum($album->getId());
1568
				$name = $album->getNameString($this->l10n);
1569
				$nameParts = $this->prefixAndBaseName($name);
1570
				$apiAlbum = [
1571
					'id' => (string)$album->getId(),
1572
					'name' => $name,
1573
					'prefix' => $nameParts['prefix'],
1574
					'basename' => $nameParts['basename'],
1575
					'artist' => $this->renderAlbumOrArtistRef(
1576
						$album->getAlbumArtistId(),
1577
						$album->getAlbumArtistNameString($this->l10n)
1578
					),
1579
					'tracks' => $songCount, // TODO: this should contain objects if requested; in API5+, this never contains the count
1580
					'songcount' => $songCount,
1581
					'time' => $this->trackBusinessLayer->totalDurationOfAlbum($album->getId()),
1582
					'rating' => $album->getRating() ?? 0,
1583
					'preciserating' => $album->getRating() ?? 0,
1584
					'year' => $album->yearToAPI(),
1585
					'art' => $this->createCoverUrl($album),
1586
					'flag' => !empty($album->getStarred()),
1587
					$genreKey => \array_map(function ($genre) {
1588
						return [
1589
							'id' => (string)$genre->getId(),
1590
							'value' => $genre->getNameString($this->l10n),
1591
							'count' => 1
1592
						];
1593
					}, $album->getGenres() ?? [])
1594
				];
1595
				if ($includeArtists) {
1596
					$apiAlbum['artists'] = [$apiAlbum['artist']];
1597
				}
1598
1599
				return $apiAlbum;
1600
			}, $albums)
1601
		];
1602
	}
1603
1604
	/**
1605
	 * @param Track[] $tracks
1606
	 */
1607
	private function renderSongs(array $tracks) : array {
1608
		$userId = $this->session->getUserId();
1609
		$this->albumBusinessLayer->injectAlbumsToTracks($tracks, $userId);
1610
1611
		$createPlayUrl = function(Track $track) : string {
1612
			return $this->createAmpacheActionUrl('download', $track->getId());
1613
		};
1614
		$createImageUrl = function(Track $track) : string {
1615
			return $this->createCoverUrl($track->getAlbum());
0 ignored issues
show
Bug introduced by
It seems like $track->getAlbum() can also be of type null; however, parameter $entity of OCA\Music\Controller\Amp...oller::createCoverUrl() does only seem to accept OCA\Music\Db\Entity, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1615
			return $this->createCoverUrl(/** @scrutinizer ignore-type */ $track->getAlbum());
Loading history...
1616
		};
1617
		$renderRef = function(int $id, string $name) : array {
1618
			return $this->renderAlbumOrArtistRef($id, $name);
1619
		};
1620
		$genreKey = $this->genreKey();
1621
		// In APIv6 JSON format, there is a new property `artists` with an array value
1622
		$includeArtists = ($this->jsonMode && $this->apiMajorVersion() > 5);
1623
1624
		return [
1625
			'song' => Util::arrayMapMethod($tracks, 'toAmpacheApi', 
1626
				[$this->l10n, $createPlayUrl, $createImageUrl, $renderRef, $genreKey, $includeArtists])
1627
		];
1628
	}
1629
1630
	/**
1631
	 * @param Playlist[] $playlists
1632
	 */
1633
	private function renderPlaylists(array $playlists) : array {
1634
		$createImageUrl = function(Playlist $playlist) : string {
1635
			if ($playlist->getId() === self::ALL_TRACKS_PLAYLIST_ID) {
1636
				return '';
1637
			} else {
1638
				return $this->createCoverUrl($playlist);
1639
			}
1640
		};
1641
1642
		return [
1643
			'playlist' => Util::arrayMapMethod($playlists, 'toAmpacheApi', [$createImageUrl])
1644
		];
1645
	}
1646
1647
	/**
1648
	 * @param PodcastChannel[] $channels
1649
	 */
1650
	private function renderPodcastChannels(array $channels) : array {
1651
		return [
1652
			'podcast' => Util::arrayMapMethod($channels, 'toAmpacheApi')
1653
		];
1654
	}
1655
1656
	/**
1657
	 * @param PodcastEpisode[] $episodes
1658
	 */
1659
	private function renderPodcastEpisodes(array $episodes) : array {
1660
		return [
1661
			'podcast_episode' => Util::arrayMapMethod($episodes, 'toAmpacheApi')
1662
		];
1663
	}
1664
1665
	/**
1666
	 * @param RadioStation[] $stations
1667
	 */
1668
	private function renderLiveStreams(array $stations) : array {
1669
		return [
1670
			'live_stream' => Util::arrayMapMethod($stations, 'toAmpacheApi')
1671
		];
1672
	}
1673
1674
	/**
1675
	 * @param Genre[] $genres
1676
	 */
1677
	private function renderTags(array $genres) : array {
1678
		return [
1679
			'tag' => Util::arrayMapMethod($genres, 'toAmpacheApi', [$this->l10n])
1680
		];
1681
	}
1682
1683
	/**
1684
	 * @param Genre[] $genres
1685
	 */
1686
	private function renderGenres(array $genres) : array {
1687
		return [
1688
			'genre' => Util::arrayMapMethod($genres, 'toAmpacheApi', [$this->l10n])
1689
		];
1690
	}
1691
1692
	/**
1693
	 * @param Bookmark[] $bookmarks
1694
	 */
1695
	private function renderBookmarks(array $bookmarks) : array {
1696
		return [
1697
			'bookmark' => Util::arrayMapMethod($bookmarks, 'toAmpacheApi')
1698
		];
1699
	}
1700
1701
	/**
1702
	 * @param Track[] $tracks
1703
	 */
1704
	private function renderSongsIndex(array $tracks) : array {
1705
		return [
1706
			'song' => \array_map(function ($track) {
1707
				return [
1708
					'id' => (string)$track->getId(),
1709
					'title' => $track->getTitle(),
1710
					'name' => $track->getTitle(),
1711
					'artist' => $this->renderAlbumOrArtistRef($track->getArtistId(), $track->getArtistNameString($this->l10n)),
1712
					'album' => $this->renderAlbumOrArtistRef($track->getAlbumId(), $track->getAlbumNameString($this->l10n))
1713
				];
1714
			}, $tracks)
1715
		];
1716
	}
1717
1718
	/**
1719
	 * @param Album[] $albums
1720
	 */
1721
	private function renderAlbumsIndex(array $albums) : array {
1722
		return [
1723
			'album' => \array_map(function ($album) {
1724
				$name = $album->getNameString($this->l10n);
1725
				$nameParts = $this->prefixAndBaseName($name);
1726
1727
				return [
1728
					'id' => (string)$album->getId(),
1729
					'name' => $name,
1730
					'prefix' => $nameParts['prefix'],
1731
					'basename' => $nameParts['basename'],
1732
					'artist' => $this->renderAlbumOrArtistRef($album->getAlbumArtistId(), $album->getAlbumArtistNameString($this->l10n))
1733
				];
1734
			}, $albums)
1735
		];
1736
	}
1737
1738
	/**
1739
	 * @param Artist[] $artists
1740
	 */
1741
	private function renderArtistsIndex(array $artists) : array {
1742
		return [
1743
			'artist' => \array_map(function ($artist) {
1744
				$userId = $this->session->getUserId();
1745
				$albums = $this->albumBusinessLayer->findAllByArtist($artist->getId(), $userId);
1746
				$name = $artist->getNameString($this->l10n);
1747
				$nameParts = $this->prefixAndBaseName($name);
1748
1749
				return [
1750
					'id' => (string)$artist->getId(),
1751
					'name' => $name,
1752
					'prefix' => $nameParts['prefix'],
1753
					'basename' => $nameParts['basename'],
1754
					'album' => \array_map(function ($album) {
1755
						return $this->renderAlbumOrArtistRef($album->getId(), $album->getNameString($this->l10n));
1756
					}, $albums)
1757
				];
1758
			}, $artists)
1759
		];
1760
	}
1761
1762
	/**
1763
	 * @param Playlist[] $playlists
1764
	 */
1765
	private function renderPlaylistsIndex(array $playlists) : array {
1766
		return [
1767
			'playlist' => \array_map(function ($playlist) {
1768
				return [
1769
					'id' => (string)$playlist->getId(),
1770
					'name' => $playlist->getName(),
1771
					'playlisttrack' => $playlist->getTrackIdsAsArray()
1772
				];
1773
			}, $playlists)
1774
		];
1775
	}
1776
1777
	/**
1778
	 * @param PodcastChannel[] $channels
1779
	 */
1780
	private function renderPodcastChannelsIndex(array $channels) : array {
1781
		// The v4 API spec does not give any examples of this, and the v5 example is almost identical to the v4 "normal" result
1782
		return $this->renderPodcastChannels($channels);
1783
	}
1784
1785
	/**
1786
	 * @param PodcastEpisode[] $episodes
1787
	 */
1788
	private function renderPodcastEpisodesIndex(array $episodes) : array {
1789
		// The v4 API spec does not give any examples of this, and the v5 example is almost identical to the v4 "normal" result
1790
		return $this->renderPodcastEpisodes($episodes);
1791
	}
1792
1793
	/**
1794
	 * @param RadioStation[] $stations
1795
	 */
1796
	private function renderLiveStreamsIndex(array $stations) : array {
1797
		// The API spec gives no examples of this, but testing with Ampache demo server revealed that the format is indentical to the "full" format
1798
		return $this->renderLiveStreams($stations);
1799
	}
1800
1801
	/**
1802
	 * @param Entity[] $entities
1803
	 */
1804
	private function renderEntityIds(array $entities) : array {
1805
		return ['id' => Util::extractIds($entities)];
1806
	}
1807
1808
	/**
1809
	 * Array is considered to be "indexed" if its first element has numerical key.
1810
	 * Empty array is considered to be "indexed".
1811
	 */
1812
	private static function arrayIsIndexed(array $array) : bool {
1813
		\reset($array);
1814
		return empty($array) || \is_int(\key($array));
1815
	}
1816
1817
	/**
1818
	 * The JSON API has some asymmetries with the XML API. This function makes the needed
1819
	 * translations for the result content before it is converted into JSON.
1820
	 */
1821
	private function prepareResultForJsonApi(array $content) : array {
1822
		$apiVer = $this->apiMajorVersion();
1823
1824
		// Special handling is needed for responses returning an array of library entities,
1825
		// depending on the API version. In these cases, the outermost array is of associative
1826
		// type with a single value which is a non-associative array.
1827
		if (\count($content) === 1 && !self::arrayIsIndexed($content)
1828
				&& \is_array(\current($content)) && self::arrayIsIndexed(\current($content))) {
1829
			// In API versions < 5, the root node is an anonymous array. Unwrap the outermost array.
1830
			if ($apiVer < 5) {
1831
				$content = \array_pop($content);
1832
			}
1833
			// In later versions, the root object has a named array for plural actions (like "songs", "artists").
1834
			// For singular actions (like "song", "artist"), the root object contains directly the entity properties.
1835
			else {
1836
				$action = $this->request->getParam('action');
1837
				$plural = (\substr($action, -1) === 's' || $action === 'get_similar' || $action === 'advanced_search');
1838
1839
				// In APIv5, the action "album" is an excption, it is formatted as if it was a plural action.
1840
				// This outlier has been fixed in APIv6.
1841
				$api5albumOddity = ($apiVer === 5 && $action === 'album');
1842
1843
				if (!($plural  || $api5albumOddity)) {
1844
					$content = \array_pop($content);
1845
					$content = \array_pop($content);
1846
				}
1847
			}
1848
		}
1849
1850
		// In API versions < 6, all boolean valued properties should be converted to 0/1.
1851
		if ($apiVer < 6) {
1852
			Util::intCastArrayValues($content, 'is_bool');
1853
		}
1854
1855
		// The key 'value' has a special meaning on XML responses, as it makes the corresponding value
1856
		// to be treated as text content of the parent element. In the JSON API, these are mostly
1857
		// substituted with property 'name', but error responses use the property 'message', instead.
1858
		if (\array_key_exists('error', $content)) {
1859
			$content = Util::convertArrayKeys($content, ['value' => 'message']);
1860
		} else {
1861
			$content = Util::convertArrayKeys($content, ['value' => 'name']);
1862
		}
1863
		return $content;
1864
	}
1865
1866
	/**
1867
	 * The XML API has some asymmetries with the JSON API. This function makes the needed
1868
	 * translations for the result content before it is converted into XML.
1869
	 */
1870
	private function prepareResultForXmlApi(array $content) : array {
1871
		\reset($content);
1872
		$firstKey = \key($content);
1873
1874
		// all 'entity list' kind of responses shall have the (deprecated) total_count element
1875
		if ($firstKey == 'song' || $firstKey == 'album' || $firstKey == 'artist' || $firstKey == 'playlist'
1876
				|| $firstKey == 'tag' || $firstKey == 'genre' || $firstKey == 'podcast' || $firstKey == 'podcast_episode'
1877
				|| $firstKey == 'live_stream') {
1878
			$content = ['total_count' => \count($content[$firstKey])] + $content;
1879
		}
1880
1881
		// for some bizarre reason, the 'id' arrays have 'index' attributes in the XML format
1882
		if ($firstKey == 'id') {
1883
			$content['id'] = \array_map(function ($id, $index) {
1884
				return ['index' => $index, 'value' => $id];
1885
			}, $content['id'], \array_keys($content['id']));
1886
		}
1887
1888
		return ['root' => $content];
1889
	}
1890
1891
	private function genreKey() : string {
1892
		return ($this->apiMajorVersion() > 4) ? 'genre' : 'tag';
1893
	}
1894
1895
	private function apiMajorVersion() : int {
1896
		// During the handshake, we don't yet have a session but the requeted version may be in the request args
1897
		$verString = ($this->session !== null) 
1898
			? $this->session->getApiVersion()
1899
			: $this->request->getParam('version');
1900
		
1901
		if (\is_string($verString) && \strlen($verString)) {
1902
			$ver = (int)$verString[0];
1903
		} else {
1904
			// Default version is 6 unless otherwise defined in config.php
1905
			$ver = (int)$this->config->getSystemValue('music.ampache_api_default_ver', 6);
1906
		}
1907
1908
		// For now, we have three supported major versions. Major version 3 can be sufficiently supported
1909
		// with our "version 4" implementation.
1910
		return Util::limit($ver, 4, 6);
0 ignored issues
show
Bug Best Practice introduced by
The expression return OCA\Music\Utility\Util::limit($ver, 4, 6) could return the type null which is incompatible with the type-hinted return integer. Consider adding an additional type-check to rule them out.
Loading history...
1911
	}
1912
1913
	private function apiVersionString() : string {
1914
		switch ($this->apiMajorVersion()) {
1915
			case 4:		return self::API4_VERSION;
1916
			case 5:		return self::API5_VERSION;
1917
			case 6:		return self::API6_VERSION;
1918
			default:	throw new AmpacheException('Unexpected api major version', 500);
1919
		}
1920
	}
1921
1922
	private function mapApiV4ErrorToV5(int $code) : int {
1923
		switch ($code) {
1924
			case 400:	return 4710;	// bad request
1925
			case 401:	return 4701;	// invalid handshake
1926
			case 403:	return 4703;	// access denied
1927
			case 404:	return 4704;	// not found
1928
			case 405:	return 4705;	// missing
1929
			case 412:	return 4742;	// failed access check
1930
			case 501:	return 4700;	// access control not enabled
1931
			default:	return 5000;	// unexcpected (not part of the API spec)
1932
		}
1933
	}
1934
}
1935
1936
/**
1937
 * Adapter class which acts like the Playlist class for the purpose of
1938
 * AmpacheController::renderPlaylists but contains all the track of the user.
1939
 */
1940
class AmpacheController_AllTracksPlaylist extends Playlist {
1941
	private $trackBusinessLayer;
1942
	private $l10n;
1943
1944
	public function __construct(string $userId, TrackBusinessLayer $trackBusinessLayer, IL10N $l10n) {
1945
		$this->userId = $userId;
1946
		$this->trackBusinessLayer = $trackBusinessLayer;
1947
		$this->l10n = $l10n;
1948
	}
1949
1950
	public function getId() : int {
1951
		return AmpacheController::ALL_TRACKS_PLAYLIST_ID;
1952
	}
1953
1954
	public function getName() : string {
1955
		return $this->l10n->t('All tracks');
1956
	}
1957
1958
	public function getTrackCount() : int {
1959
		return $this->trackBusinessLayer->count($this->userId);
1960
	}
1961
}
1962