Passed
Pull Request — master (#1078)
by Pauli
05:42 queued 02:37
created

AmpacheController::playlist_generate()   B

Complexity

Conditions 8
Paths 40

Size

Total Lines 42
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 8
eloc 26
c 0
b 0
f 0
nc 40
nop 8
dl 0
loc 42
rs 8.4444

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': //TODO
392
			default:
393
				throw new AmpacheException("Unsupported filter $filter", 400);
394
		}
395
396
		return $this->renderEntities($entities, $type);
397
	}
398
399
	/**
400
	 * @AmpacheAPI
401
	 */
402
	protected function artists(
403
			?string $filter, ?string $add, ?string $update,
404
			int $limit, int $offset=0, bool $exact=false) : array {
405
406
		$artists = $this->findEntities($this->artistBusinessLayer, $filter, $exact, $limit, $offset, $add, $update);
407
		return $this->renderArtists($artists);
408
	}
409
410
	/**
411
	 * @AmpacheAPI
412
	 */
413
	protected function artist(int $filter) : array {
414
		$userId = $this->session->getUserId();
415
		$artist = $this->artistBusinessLayer->find($filter, $userId);
416
		return $this->renderArtists([$artist]);
417
	}
418
419
	/**
420
	 * @AmpacheAPI
421
	 */
422
	protected function artist_albums(int $filter, int $limit, int $offset=0) : array {
423
		$userId = $this->session->getUserId();
424
		$albums = $this->albumBusinessLayer->findAllByArtist($filter, $userId, $limit, $offset);
425
		return $this->renderAlbums($albums);
426
	}
427
428
	/**
429
	 * @AmpacheAPI
430
	 */
431
	protected function artist_songs(int $filter, int $limit, int $offset=0, bool $top50=false) : array {
432
		$userId = $this->session->getUserId();
433
		if ($top50) {
434
			$tracks = $this->lastfmService->getTopTracks($filter, $userId, 50);
435
			$tracks = \array_slice($tracks, $offset, $limit);
436
		} else {
437
			$tracks = $this->trackBusinessLayer->findAllByArtist($filter, $userId, $limit, $offset);
438
		}
439
		return $this->renderSongs($tracks);
440
	}
441
442
	/**
443
	 * @AmpacheAPI
444
	 */
445
	protected function album_songs(int $filter, int $limit, int $offset=0) : array {
446
		$userId = $this->session->getUserId();
447
		$tracks = $this->trackBusinessLayer->findAllByAlbum($filter, $userId, null, $limit, $offset);
448
		return $this->renderSongs($tracks);
449
	}
450
451
	/**
452
	 * @AmpacheAPI
453
	 */
454
	protected function song(int $filter) : array {
455
		$userId = $this->session->getUserId();
456
		$track = $this->trackBusinessLayer->find($filter, $userId);
457
		$trackInArray = [$track];
458
		return $this->renderSongs($trackInArray);
459
	}
460
461
	/**
462
	 * @AmpacheAPI
463
	 */
464
	protected function songs(
465
			?string $filter, ?string $add, ?string $update,
466
			int $limit, int $offset=0, bool $exact=false) : array {
467
468
		$tracks = $this->findEntities($this->trackBusinessLayer, $filter, $exact, $limit, $offset, $add, $update);
469
		return $this->renderSongs($tracks);
470
	}
471
472
	/**
473
	 * @AmpacheAPI
474
	 */
475
	protected function search_songs(string $filter, int $limit, int $offset=0) : array {
476
		$userId = $this->session->getUserId();
477
		$tracks = $this->trackBusinessLayer->findAllByNameRecursive($filter, $userId, $limit, $offset);
478
		return $this->renderSongs($tracks);
479
	}
480
481
	/**
482
	 * @AmpacheAPI
483
	 */
484
	protected function albums(
485
			?string $filter, ?string $add, ?string $update,
486
			int $limit, int $offset=0, bool $exact=false) : array {
487
488
		$albums = $this->findEntities($this->albumBusinessLayer, $filter, $exact, $limit, $offset, $add, $update);
489
		return $this->renderAlbums($albums);
490
	}
491
492
	/**
493
	 * @AmpacheAPI
494
	 */
495
	protected function album(int $filter) : array {
496
		$userId = $this->session->getUserId();
497
		$album = $this->albumBusinessLayer->find($filter, $userId);
498
		return $this->renderAlbums([$album]);
499
	}
500
501
	/**
502
	 * @AmpacheAPI
503
	 */
504
	protected function get_similar(string $type, int $filter, int $limit, int $offset=0) : array {
505
		$userId = $this->session->getUserId();
506
		if ($type == 'artist') {
507
			$entities = $this->lastfmService->getSimilarArtists($filter, $userId);
508
		} elseif ($type == 'song') {
509
			$entities = $this->lastfmService->getSimilarTracks($filter, $userId);
510
		} else {
511
			throw new AmpacheException("Type '$type' is not supported", 400);
512
		}
513
		$entities = \array_slice($entities, $offset, $limit);
514
		return $this->renderEntitiesIndex($entities, $type);
515
	}
516
517
	/**
518
	 * @AmpacheAPI
519
	 */
520
	protected function playlists(
521
			?string $filter, ?string $add, ?string $update,
522
			int $limit, int $offset=0, bool $exact=false, int $hide_search=0) : array {
523
524
		$userId = $this->session->getUserId();
525
		$playlists = $this->findEntities($this->playlistBusinessLayer, $filter, $exact, $limit, $offset, $add, $update);
526
527
		// append "All tracks" if "seaches" are not forbidden, and not filtering by any criteria, and it is not off-limits
528
		$allTracksIndex = $this->playlistBusinessLayer->count($userId);
529
		if (!$hide_search && empty($filter) && empty($add) && empty($update)
530
				&& self::indexIsWithinOffsetAndLimit($allTracksIndex, $offset, $limit)) {
531
			$playlists[] = new AmpacheController_AllTracksPlaylist($userId, $this->trackBusinessLayer, $this->l10n);
532
		}
533
534
		return $this->renderPlaylists($playlists);
535
	}
536
537
	/**
538
	 * @AmpacheAPI
539
	 */
540
	protected function playlist(int $filter) : array {
541
		$userId = $this->session->getUserId();
542
		if ($filter == self::ALL_TRACKS_PLAYLIST_ID) {
543
			$playlist = new AmpacheController_AllTracksPlaylist($userId, $this->trackBusinessLayer, $this->l10n);
544
		} else {
545
			$playlist = $this->playlistBusinessLayer->find($filter, $userId);
546
		}
547
		return $this->renderPlaylists([$playlist]);
548
	}
549
550
	/**
551
	 * @AmpacheAPI
552
	 */
553
	protected function playlist_songs(int $filter, int $limit, int $offset=0) : array {
554
		$userId = $this->session->getUserId();
555
		if ($filter == self::ALL_TRACKS_PLAYLIST_ID) {
556
			$tracks = $this->trackBusinessLayer->findAll($userId, SortBy::Parent, $limit, $offset);
557
			foreach ($tracks as $index => &$track) {
558
				$track->setNumberOnPlaylist($index + 1);
559
			}
560
		} else {
561
			$tracks = $this->playlistBusinessLayer->getPlaylistTracks($filter, $userId, $limit, $offset);
562
		}
563
		return $this->renderSongs($tracks);
564
	}
565
566
	/**
567
	 * @AmpacheAPI
568
	 */
569
	protected function playlist_create(string $name) : array {
570
		$playlist = $this->playlistBusinessLayer->create($name, $this->session->getUserId());
571
		return $this->renderPlaylists([$playlist]);
572
	}
573
574
	/**
575
	 * @AmpacheAPI
576
	 *
577
	 * @param int $filter Playlist ID
578
	 * @param ?string $name New name for the playlist
579
	 * @param ?string $items Track IDs
580
	 * @param ?string $tracks 1-based indices of the tracks
581
	 */
582
	protected function playlist_edit(int $filter, ?string $name, ?string $items, ?string $tracks) : array {
583
		$edited = false;
584
		$userId = $this->session->getUserId();
585
		$playlist = $this->playlistBusinessLayer->find($filter, $userId);
586
587
		if (!empty($name)) {
588
			$playlist->setName($name);
589
			$edited = true;
590
		}
591
592
		$newTrackIds = Util::explode(',', $items);
593
		$newTrackOrdinals = Util::explode(',', $tracks);
594
595
		if (\count($newTrackIds) != \count($newTrackOrdinals)) {
596
			throw new AmpacheException("Arguments 'items' and 'tracks' must contain equal amount of elements", 400);
597
		} elseif (\count($newTrackIds) > 0) {
598
			$trackIds = $playlist->getTrackIdsAsArray();
599
600
			for ($i = 0, $count = \count($newTrackIds); $i < $count; ++$i) {
601
				$trackId = $newTrackIds[$i];
602
				if (!$this->trackBusinessLayer->exists($trackId, $userId)) {
603
					throw new AmpacheException("Invalid song ID $trackId", 404);
604
				}
605
				$trackIds[$newTrackOrdinals[$i]-1] = $trackId;
606
			}
607
608
			$playlist->setTrackIdsFromArray($trackIds);
609
			$edited = true;
610
		}
611
612
		if ($edited) {
613
			$this->playlistBusinessLayer->update($playlist);
614
			return ['success' => 'playlist changes saved'];
615
		} else {
616
			throw new AmpacheException('Nothing was changed', 400);
617
		}
618
	}
619
620
	/**
621
	 * @AmpacheAPI
622
	 */
623
	protected function playlist_delete(int $filter) : array {
624
		$this->playlistBusinessLayer->delete($filter, $this->session->getUserId());
625
		return ['success' => 'playlist deleted'];
626
	}
627
628
	/**
629
	 * @AmpacheAPI
630
	 */
631
	protected function playlist_add_song(int $filter, int $song, bool $check=false) : array {
632
		$userId = $this->session->getUserId();
633
		if (!$this->trackBusinessLayer->exists($song, $userId)) {
634
			throw new AmpacheException("Invalid song ID $song", 404);
635
		}
636
637
		$playlist = $this->playlistBusinessLayer->find($filter, $userId);
638
		$trackIds = $playlist->getTrackIdsAsArray();
639
640
		if ($check && \in_array($song, $trackIds)) {
641
			throw new AmpacheException("Can't add a duplicate item when check is enabled", 400);
642
		}
643
644
		$trackIds[] = $song;
645
		$playlist->setTrackIdsFromArray($trackIds);
646
		$this->playlistBusinessLayer->update($playlist);
647
		return ['success' => 'song added to playlist'];
648
	}
649
650
	/**
651
	 * @AmpacheAPI
652
	 *
653
	 * @param int $filter Playlist ID
654
	 * @param ?int $song Track ID
655
	 * @param ?int $track 1-based index of the track
656
	 * @param ?int $clear Value 1 erases all the songs from the playlist
657
	 */
658
	protected function playlist_remove_song(int $filter, ?int $song, ?int $track, ?int $clear) : array {
659
		$playlist = $this->playlistBusinessLayer->find($filter, $this->session->getUserId());
660
661
		if ($clear === 1) {
662
			$trackIds = [];
663
			$message = 'all songs removed from playlist';
664
		} elseif ($song !== null) {
665
			$trackIds = $playlist->getTrackIdsAsArray();
666
			if (!\in_array($song, $trackIds)) {
667
				throw new AmpacheException("Song $song not found in playlist", 404);
668
			}
669
			$trackIds = Util::arrayDiff($trackIds, [$song]);
670
			$message = 'song removed from playlist';
671
		} elseif ($track !== null) {
672
			$trackIds = $playlist->getTrackIdsAsArray();
673
			if ($track < 1 || $track > \count($trackIds)) {
674
				throw new AmpacheException("Track ordinal $track is out of bounds", 404);
675
			}
676
			unset($trackIds[$track-1]);
677
			$message = 'song removed from playlist';
678
		} else {
679
			throw new AmpacheException("One of the arguments 'clear', 'song', 'track' is required", 400);
680
		}
681
682
		$playlist->setTrackIdsFromArray($trackIds);
683
		$this->playlistBusinessLayer->update($playlist);
684
		return ['success' => $message];
685
	}
686
687
	/**
688
	 * @AmpacheAPI
689
	 */
690
	protected function playlist_generate(
691
			?string $filter, ?int $album, ?int $artist, ?int $flag,
692
			int $limit, int $offset=0, string $mode='random', string $format='song') : array {
693
694
		$tracks = $this->findEntities($this->trackBusinessLayer, $filter, false); // $limit and $offset are applied later
695
696
		// filter the found tracks according to the additional requirements
697
		if ($album !== null) {
698
			$tracks = \array_filter($tracks, function ($track) use ($album) {
699
				return ($track->getAlbumId() == $album);
700
			});
701
		}
702
		if ($artist !== null) {
703
			$tracks = \array_filter($tracks, function ($track) use ($artist) {
704
				return ($track->getArtistId() == $artist);
705
			});
706
		}
707
		if ($flag == 1) {
708
			$tracks = \array_filter($tracks, function ($track) {
709
				return ($track->getStarred() !== null);
710
			});
711
		}
712
		// After filtering, there may be "holes" between the array indices. Reindex the array.
713
		$tracks = \array_values($tracks);
714
715
		if ($mode == 'random') {
716
			$userId = $this->session->getUserId();
717
			$indices = $this->random->getIndices(\count($tracks), $offset, $limit, $userId, 'ampache_playlist_generate');
718
			$tracks = Util::arrayMultiGet($tracks, $indices);
719
		} else { // 'recent', 'forgotten', 'unplayed'
720
			throw new AmpacheException("Mode '$mode' is not supported", 400);
721
		}
722
723
		switch ($format) {
724
			case 'song':
725
				return $this->renderSongs($tracks);
726
			case 'index':
727
				return $this->renderSongsIndex($tracks);
728
			case 'id':
729
				return $this->renderEntityIds($tracks);
730
			default:
731
				throw new AmpacheException("Format '$format' is not supported", 400);
732
		}
733
	}
734
735
	/**
736
	 * @AmpacheAPI
737
	 */
738
	protected function podcasts(?string $filter, ?string $include, int $limit, int $offset=0, bool $exact=false) : array {
739
		$channels = $this->findEntities($this->podcastChannelBusinessLayer, $filter, $exact, $limit, $offset);
740
741
		if ($include === 'episodes') {
742
			$userId = $this->session->getUserId();
743
			$actuallyLimited = ($limit < $this->podcastChannelBusinessLayer->count($userId));
744
			$allChannelsIncluded = (!$filter && !$actuallyLimited && !$offset);
745
			$this->podcastService->injectEpisodes($channels, $userId, $allChannelsIncluded);
746
		}
747
748
		return $this->renderPodcastChannels($channels);
749
	}
750
751
	/**
752
	 * @AmpacheAPI
753
	 */
754
	protected function podcast(int $filter, ?string $include) : array {
755
		$userId = $this->session->getUserId();
756
		$channel = $this->podcastChannelBusinessLayer->find($filter, $userId);
757
758
		if ($include === 'episodes') {
759
			$channel->setEpisodes($this->podcastEpisodeBusinessLayer->findAllByChannel($filter, $userId));
760
		}
761
762
		return $this->renderPodcastChannels([$channel]);
763
	}
764
765
	/**
766
	 * @AmpacheAPI
767
	 */
768
	protected function podcast_create(string $url) : array {
769
		$userId = $this->session->getUserId();
770
		$result = $this->podcastService->subscribe($url, $userId);
771
772
		switch ($result['status']) {
773
			case PodcastService::STATUS_OK:
774
				return $this->renderPodcastChannels([$result['channel']]);
775
			case PodcastService::STATUS_INVALID_URL:
776
				throw new AmpacheException("Invalid URL $url", 400);
777
			case PodcastService::STATUS_INVALID_RSS:
778
				throw new AmpacheException("The document at URL $url is not a valid podcast RSS feed", 400);
779
			case PodcastService::STATUS_ALREADY_EXISTS:
780
				throw new AmpacheException('User already has this podcast channel subscribed', 400);
781
			default:
782
				throw new AmpacheException("Unexpected status code {$result['status']}", 400);
783
		}
784
	}
785
786
	/**
787
	 * @AmpacheAPI
788
	 */
789
	protected function podcast_delete(int $filter) : array {
790
		$userId = $this->session->getUserId();
791
		$status = $this->podcastService->unsubscribe($filter, $userId);
792
793
		switch ($status) {
794
			case PodcastService::STATUS_OK:
795
				return ['success' => 'podcast deleted'];
796
			case PodcastService::STATUS_NOT_FOUND:
797
				throw new AmpacheException('Channel to be deleted not found', 404);
798
			default:
799
				throw new AmpacheException("Unexpected status code $status", 400);
800
		}
801
	}
802
803
	/**
804
	 * @AmpacheAPI
805
	 */
806
	protected function podcast_episodes(int $filter, int $limit, int $offset=0) : array {
807
		$userId = $this->session->getUserId();
808
		$episodes = $this->podcastEpisodeBusinessLayer->findAllByChannel($filter, $userId, $limit, $offset);
809
		return $this->renderPodcastEpisodes($episodes);
810
	}
811
812
	/**
813
	 * @AmpacheAPI
814
	 */
815
	protected function podcast_episode(int $filter) : array {
816
		$userId = $this->session->getUserId();
817
		$episode = $this->podcastEpisodeBusinessLayer->find($filter, $userId);
818
		return $this->renderPodcastEpisodes([$episode]);
819
	}
820
821
	/**
822
	 * @AmpacheAPI
823
	 */
824
	protected function update_podcast(int $id) : array {
825
		$userId = $this->session->getUserId();
826
		$result = $this->podcastService->updateChannel($id, $userId);
827
828
		switch ($result['status']) {
829
			case PodcastService::STATUS_OK:
830
				$message = $result['updated'] ? 'channel was updated from the source' : 'no changes found';
831
				return ['success' => $message];
832
			case PodcastService::STATUS_NOT_FOUND:
833
				throw new AmpacheException('Channel to be updated not found', 404);
834
			case PodcastService::STATUS_INVALID_URL:
835
				throw new AmpacheException('failed to read from the channel URL', 400);
836
			case PodcastService::STATUS_INVALID_RSS:
837
				throw new AmpacheException('the document at the channel URL is not a valid podcast RSS feed', 400);
838
			default:
839
				throw new AmpacheException("Unexpected status code {$result['status']}", 400);
840
		}
841
	}
842
843
	/**
844
	 * @AmpacheAPI
845
	 */
846
	protected function live_streams(?string $filter, int $limit, int $offset=0, bool $exact=false) : array {
847
		$stations = $this->findEntities($this->radioStationBusinessLayer, $filter, $exact, $limit, $offset);
848
		return $this->renderLiveStreams($stations);
849
	}
850
851
	/**
852
	 * @AmpacheAPI
853
	 */
854
	protected function live_stream(int $filter) : array {
855
		$station = $this->radioStationBusinessLayer->find($filter, $this->session->getUserId());
856
		return $this->renderLiveStreams([$station]);
857
	}
858
859
	/**
860
	 * @AmpacheAPI
861
	 */
862
	protected function live_stream_create(string $name, string $url, ?string $site_url) : array {
863
		$station = $this->radioStationBusinessLayer->create($this->session->getUserId(), $name, $url, $site_url);
864
		return $this->renderLiveStreams([$station]);
865
	}
866
867
	/**
868
	 * @AmpacheAPI
869
	 */
870
	protected function live_stream_delete(int $filter) : array {
871
		$this->radioStationBusinessLayer->delete($filter, $this->session->getUserId());
872
		return ['success' => "Deleted live stream: $filter"];
873
	}
874
875
	/**
876
	 * @AmpacheAPI
877
	 */
878
	protected function live_stream_edit(int $filter, ?string $name, ?string $url, ?string $site_url) : array {
879
		$station = $this->radioStationBusinessLayer->find($filter, $this->session->getUserId());
880
881
		if ($name !== null) {
882
			$station->setName($name);
883
		}
884
		if ($url !== null) {
885
			$station->setStreamUrl($url);
886
		}
887
		if ($site_url !== null) {
888
			$station->setHomeUrl($site_url);
889
		}
890
		$station = $this->radioStationBusinessLayer->update($station);
891
892
		return $this->renderLiveStreams([$station]);
893
	}
894
895
	/**
896
	 * @AmpacheAPI
897
	 */
898
	protected function tags(?string $filter, int $limit, int $offset=0, bool $exact=false) : array {
899
		$genres = $this->findEntities($this->genreBusinessLayer, $filter, $exact, $limit, $offset);
900
		return $this->renderTags($genres);
901
	}
902
903
	/**
904
	 * @AmpacheAPI
905
	 */
906
	protected function tag(int $filter) : array {
907
		$userId = $this->session->getUserId();
908
		$genre = $this->genreBusinessLayer->find($filter, $userId);
909
		return $this->renderTags([$genre]);
910
	}
911
912
	/**
913
	 * @AmpacheAPI
914
	 */
915
	protected function tag_artists(int $filter, int $limit, int $offset=0) : array {
916
		$userId = $this->session->getUserId();
917
		$artists = $this->artistBusinessLayer->findAllByGenre($filter, $userId, $limit, $offset);
918
		return $this->renderArtists($artists);
919
	}
920
921
	/**
922
	 * @AmpacheAPI
923
	 */
924
	protected function tag_albums(int $filter, int $limit, int $offset=0) : array {
925
		$userId = $this->session->getUserId();
926
		$albums = $this->albumBusinessLayer->findAllByGenre($filter, $userId, $limit, $offset);
927
		return $this->renderAlbums($albums);
928
	}
929
930
	/**
931
	 * @AmpacheAPI
932
	 */
933
	protected function tag_songs(int $filter, int $limit, int $offset=0) : array {
934
		$userId = $this->session->getUserId();
935
		$tracks = $this->trackBusinessLayer->findAllByGenre($filter, $userId, $limit, $offset);
936
		return $this->renderSongs($tracks);
937
	}
938
939
	/**
940
	 * @AmpacheAPI
941
	 */
942
	protected function genres(?string $filter, int $limit, int $offset=0, bool $exact=false) : array {
943
		$genres = $this->findEntities($this->genreBusinessLayer, $filter, $exact, $limit, $offset);
944
		return $this->renderGenres($genres);
945
	}
946
947
	/**
948
	 * @AmpacheAPI
949
	 */
950
	protected function genre(int $filter) : array {
951
		$userId = $this->session->getUserId();
952
		$genre = $this->genreBusinessLayer->find($filter, $userId);
953
		return $this->renderGenres([$genre]);
954
	}
955
956
	/**
957
	 * @AmpacheAPI
958
	 */
959
	protected function genre_artists(?int $filter, int $limit, int $offset=0) : array {
960
		if ($filter === null) {
961
			return $this->artists(null, null, null, $limit, $offset);
962
		} else {
963
			return $this->tag_artists($filter, $limit, $offset);
964
		}
965
	}
966
967
	/**
968
	 * @AmpacheAPI
969
	 */
970
	protected function genre_albums(?int $filter, int $limit, int $offset=0) : array {
971
		if ($filter === null) {
972
			return $this->albums(null, null, null, $limit, $offset);
973
		} else {
974
			return $this->tag_albums($filter, $limit, $offset);
975
		}
976
	}
977
978
	/**
979
	 * @AmpacheAPI
980
	 */
981
	protected function genre_songs(?int $filter, int $limit, int $offset=0) : array {
982
		if ($filter === null) {
983
			return $this->songs(null, null, null, $limit, $offset);
984
		} else {
985
			return $this->tag_songs($filter, $limit, $offset);
986
		}
987
	}
988
989
	/**
990
	 * @AmpacheAPI
991
	 */
992
	protected function bookmarks() : array {
993
		$bookmarks = $this->bookmarkBusinessLayer->findAll($this->session->getUserId());
994
		return $this->renderBookmarks($bookmarks);
995
	}
996
997
	/**
998
	 * @AmpacheAPI
999
	 */
1000
	protected function get_bookmark(int $filter, string $type) : array {
1001
		$entryType = self::mapBookmarkType($type);
1002
		$bookmark = $this->bookmarkBusinessLayer->findByEntry($entryType, $filter, $this->session->getUserId());
1003
		return $this->renderBookmarks([$bookmark]);
1004
	}
1005
1006
	/**
1007
	 * @AmpacheAPI
1008
	 */
1009
	protected function bookmark_create(int $filter, string $type, int $position, string $client='AmpacheAPI') : array {
1010
		// Note: the optional argument 'date' is not supported and is disregarded
1011
		$entryType = self::mapBookmarkType($type);
1012
		$position *= 1000; // seconds to milliseconds
1013
		$bookmark = $this->bookmarkBusinessLayer->addOrUpdate($this->session->getUserId(), $entryType, $filter, $position, $client);
1014
		return $this->renderBookmarks([$bookmark]);
1015
	}
1016
1017
	/**
1018
	 * @AmpacheAPI
1019
	 */
1020
	protected function bookmark_edit(int $filter, string $type, int $position, ?string $client) : array {
1021
		// Note: the optional argument 'date' is not supported and is disregarded
1022
		$entryType = self::mapBookmarkType($type);
1023
		$bookmark = $this->bookmarkBusinessLayer->findByEntry($entryType, $filter, $this->session->getUserId());
1024
		$bookmark->setPosition($position * 1000); // seconds to milliseconds
1025
		if ($client !== null) {
1026
			$bookmark->setComment($client);
1027
		}
1028
		$bookmark = $this->bookmarkBusinessLayer->update($bookmark);
1029
		return $this->renderBookmarks([$bookmark]);
1030
	}
1031
1032
	/**
1033
	 * @AmpacheAPI
1034
	 */
1035
	protected function bookmark_delete(int $filter, string $type) : array {
1036
		$entryType = self::mapBookmarkType($type);
1037
		$bookmark = $this->bookmarkBusinessLayer->findByEntry($entryType, $filter, $this->session->getUserId());
1038
		$this->bookmarkBusinessLayer->delete($bookmark->getId(), $bookmark->getUserId());
1039
		return ['success' => "Deleted Bookmark: $type $filter"];
1040
	}
1041
1042
	/**
1043
	 * @AmpacheAPI
1044
	 */
1045
	protected function advanced_search(string $type, string $operator, int $limit, int $offset=0, bool $random=false) : array {
1046
		// get all the rule parameters as passed on the HTTP call
1047
		$rules = self::advSearchGetRuleParams($this->request->getParams());
1048
1049
		// apply some conversions on the rules
1050
		foreach ($rules as &$rule) {
1051
			$rule['rule'] = self::advSearchResolveRuleAlias($rule['rule']);
1052
			$rule['operator'] = self::advSearchInterpretOperator($rule['operator'], $rule['rule']);
1053
			$rule['input'] = self::advSearchConvertInput($rule['input'], $rule['rule']);
1054
		}
1055
1056
		try {
1057
			$businessLayer = $this->getBusinessLayer($type);
1058
			$userId = $this->session->getUserId();
1059
			if ($random) {
1060
				// in case the random order is requested, the limit/offset handling happens after the DB query
1061
				$entities = $businessLayer->findAllAdvanced($operator, $rules, $userId);
1062
				$indices = $this->random->getIndices(\count($entities), $offset, $limit, $userId, 'ampache_adv_search_'.$type);
1063
				$entities = Util::arrayMultiGet($entities, $indices);
1064
			} else {
1065
				$entities = $businessLayer->findAllAdvanced($operator, $rules, $userId, $limit, $offset);
1066
			}
1067
		} catch (BusinessLayerException $e) {
1068
			throw new AmpacheException($e->getMessage(), 400);
1069
		}
1070
		
1071
		return $this->renderEntities($entities, $type);
1072
	}
1073
1074
	/**
1075
	 * @AmpacheAPI
1076
	 */
1077
	protected function flag(string $type, int $id, bool $flag) : array {
1078
		if (!\in_array($type, ['song', 'album', 'artist', 'podcast', 'podcast_episode'])) {
1079
			throw new AmpacheException("Unsupported type $type", 400);
1080
		}
1081
1082
		$userId = $this->session->getUserId();
1083
		$businessLayer = $this->getBusinessLayer($type);
1084
		if ($flag) {
1085
			$modifiedCount = $businessLayer->setStarred([$id], $userId);
1086
			$message = "flag ADDED to $type $id";
1087
		} else {
1088
			$modifiedCount = $businessLayer->unsetStarred([$id], $userId);
1089
			$message = "flag REMOVED from $type $id";
1090
		}
1091
1092
		if ($modifiedCount > 0) {
1093
			return ['success' => $message];
1094
		} else {
1095
			throw new AmpacheException("The $type $id was not found", 404);
1096
		}
1097
	}
1098
1099
	/**
1100
	 * @AmpacheAPI
1101
	 */
1102
	protected function record_play(int $id, ?int $date) : array {
1103
		$timeOfPlay = ($date === null) ? null : new \DateTime('@' . $date);
1104
		$this->trackBusinessLayer->recordTrackPlayed($id, $this->session->getUserId(), $timeOfPlay);
1105
		return ['success' => 'play recorded'];
1106
	}
1107
1108
	/**
1109
	 * @AmpacheAPI
1110
	 */
1111
	protected function download(int $id, string $type='song') : Response {
1112
		// request param `format` is ignored
1113
		$userId = $this->session->getUserId();
1114
1115
		if ($type === 'song') {
1116
			try {
1117
				$track = $this->trackBusinessLayer->find($id, $userId);
1118
			} catch (BusinessLayerException $e) {
1119
				return new ErrorResponse(Http::STATUS_NOT_FOUND, $e->getMessage());
1120
			}
1121
1122
			$file = $this->librarySettings->getFolder($userId)->getById($track->getFileId())[0] ?? null;
1123
1124
			if ($file instanceof \OCP\Files\File) {
1125
				return new FileStreamResponse($file);
1126
			} else {
1127
				return new ErrorResponse(Http::STATUS_NOT_FOUND);
1128
			}
1129
		} elseif ($type === 'podcast' || $type === 'podcast_episode') { // there's a difference between APIv4 and APIv5
1130
			$episode = $this->podcastEpisodeBusinessLayer->find($id, $userId);
1131
			return new RedirectResponse($episode->getStreamUrl());
1132
		} elseif ($type === 'playlist') {
1133
			$songIds = ($id === self::ALL_TRACKS_PLAYLIST_ID)
1134
				? $this->trackBusinessLayer->findAllIds($userId)
1135
				: $this->playlistBusinessLayer->find($id, $userId)->getTrackIdsAsArray();
1136
			if (empty($songIds)) {
1137
				throw new AmpacheException("The playlist $id is empty", 404);
1138
			} else {
1139
				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

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

1453
		if ($type === 'playlist' || $entity->/** @scrutinizer ignore-call */ getCoverFileId()) {
Loading history...
1454
			$id = $entity->getId();
1455
			$token = $this->imageService->getToken($type, $id, $this->session->getAmpacheUserId());
1456
			return $this->urlGenerator->linkToRouteAbsolute('music.ampacheImage.image') . "?object_type=$type&object_id=$id&token=$token";
1457
		} else {
1458
			return '';
1459
		}
1460
	}
1461
1462
	private static function indexIsWithinOffsetAndLimit(int $index, ?int $offset, ?int $limit) : bool {
1463
		$offset = \intval($offset); // missing offset is interpreted as 0-offset
1464
		return ($limit === null) || ($index >= $offset && $index < $offset + $limit);
1465
	}
1466
1467
	private function prefixAndBaseName(?string $name) : array {
1468
		$parts = ['prefix' => null, 'basename' => $name];
1469
1470
		if ($name !== null) {
1471
			foreach ($this->namePrefixes as $prefix) {
1472
				if (Util::startsWith($name, $prefix . ' ', /*ignoreCase=*/true)) {
1473
					$parts['prefix'] = $prefix;
1474
					$parts['basename'] = \substr($name, \strlen($prefix) + 1);
1475
					break;
1476
				}
1477
			}
1478
		}
1479
1480
		return $parts;
1481
	}
1482
1483
	private function renderAlbumOrArtistRef(int $id, string $name) : array {
1484
		if ($this->apiMajorVersion() > 5) {
1485
			return [
1486
				'id' => (string)$id,
1487
				'name' => $name,
1488
			] + $this->prefixAndBaseName($name);
1489
		} else {
1490
			return [
1491
				'id' => (string)$id,
1492
				'value' => $name
1493
			];
1494
		}
1495
	}
1496
1497
	/**
1498
	 * @param Artist[] $artists
1499
	 */
1500
	private function renderArtists(array $artists) : array {
1501
		$userId = $this->session->getUserId();
1502
		$genreMap = Util::createIdLookupTable($this->genreBusinessLayer->findAll($userId));
1503
		$genreKey = $this->genreKey();
1504
1505
		return [
1506
			'artist' => \array_map(function (Artist $artist) use ($userId, $genreMap, $genreKey) {
1507
				$albumCount = $this->albumBusinessLayer->countByArtist($artist->getId());
1508
				$songCount = $this->trackBusinessLayer->countByArtist($artist->getId());
1509
				$name = $artist->getNameString($this->l10n);
1510
				$nameParts = $this->prefixAndBaseName($name);
1511
				return [
1512
					'id' => (string)$artist->getId(),
1513
					'name' => $name,
1514
					'prefix' => $nameParts['prefix'],
1515
					'basename' => $nameParts['basename'],
1516
					'albums' => $albumCount, // TODO: this should contain objects if requested; in API5+, this never contains the count
1517
					'albumcount' => $albumCount,
1518
					'songs' => $songCount, // TODO: this should contain objects if requested; in API5+, this never contains the count
1519
					'songcount' => $songCount,
1520
					'time' => $this->trackBusinessLayer->totalDurationByArtist($artist->getId()),
1521
					'art' => $this->createCoverUrl($artist),
1522
					'rating' => 0,
1523
					'preciserating' => 0,
1524
					'flag' => !empty($artist->getStarred()),
1525
					$genreKey => \array_map(function ($genreId) use ($genreMap) {
1526
						return [
1527
							'id' => (string)$genreId,
1528
							'value' => $genreMap[$genreId]->getNameString($this->l10n),
1529
							'count' => 1
1530
						];
1531
					}, $this->trackBusinessLayer->getGenresByArtistId($artist->getId(), $userId))
1532
				];
1533
			}, $artists)
1534
		];
1535
	}
1536
1537
	/**
1538
	 * @param Album[] $albums
1539
	 */
1540
	private function renderAlbums(array $albums) : array {
1541
		$genreKey = $this->genreKey();
1542
		// In APIv6 JSON format, there is a new property `artists` with an array value
1543
		$includeArtists = ($this->jsonMode && $this->apiMajorVersion() > 5);
1544
1545
		return [
1546
			'album' => \array_map(function (Album $album) use ($genreKey, $includeArtists) {
1547
				$songCount = $this->trackBusinessLayer->countByAlbum($album->getId());
1548
				$name = $album->getNameString($this->l10n);
1549
				$nameParts = $this->prefixAndBaseName($name);
1550
				$apiAlbum = [
1551
					'id' => (string)$album->getId(),
1552
					'name' => $name,
1553
					'prefix' => $nameParts['prefix'],
1554
					'basename' => $nameParts['basename'],
1555
					'artist' => $this->renderAlbumOrArtistRef(
1556
						$album->getAlbumArtistId(),
1557
						$album->getAlbumArtistNameString($this->l10n)
1558
					),
1559
					'tracks' => $songCount, // TODO: this should contain objects if requested; in API5+, this never contains the count
1560
					'songcount' => $songCount,
1561
					'time' => $this->trackBusinessLayer->totalDurationOfAlbum($album->getId()),
1562
					'rating' => 0,
1563
					'year' => $album->yearToAPI(),
1564
					'art' => $this->createCoverUrl($album),
1565
					'preciserating' => 0,
1566
					'flag' => !empty($album->getStarred()),
1567
					$genreKey => \array_map(function ($genre) {
1568
						return [
1569
							'id' => (string)$genre->getId(),
1570
							'value' => $genre->getNameString($this->l10n),
1571
							'count' => 1
1572
						];
1573
					}, $album->getGenres() ?? [])
1574
				];
1575
				if ($includeArtists) {
1576
					$apiAlbum['artists'] = [$apiAlbum['artist']];
1577
				}
1578
1579
				return $apiAlbum;
1580
			}, $albums)
1581
		];
1582
	}
1583
1584
	/**
1585
	 * @param Track[] $tracks
1586
	 */
1587
	private function renderSongs(array $tracks) : array {
1588
		$userId = $this->session->getUserId();
1589
		$this->albumBusinessLayer->injectAlbumsToTracks($tracks, $userId);
1590
1591
		$createPlayUrl = function(Track $track) : string {
1592
			return $this->createAmpacheActionUrl('download', $track->getId());
1593
		};
1594
		$createImageUrl = function(Track $track) : string {
1595
			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

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