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

1141
			$entity->/** @scrutinizer ignore-call */ 
1142
            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

1141
			$entity->/** @scrutinizer ignore-call */ 
1142
            setRating($rating);
Loading history...
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

1141
			$entity->/** @scrutinizer ignore-call */ 
1142
            setRating($rating);
Loading history...
1142
			$businessLayer->update($entity);
1143
		} else {
1144
			throw new AmpacheException("Unsupported type $type", 400);
1145
		}
1146
1147
		return ['success' => "rating set to $rating for $type $id"];
1148
	}
1149
1150
	/**
1151
	 * @AmpacheAPI
1152
	 */
1153
	protected function record_play(int $id, ?int $date) : array {
1154
		$timeOfPlay = ($date === null) ? null : new \DateTime('@' . $date);
1155
		$this->trackBusinessLayer->recordTrackPlayed($id, $this->session->getUserId(), $timeOfPlay);
1156
		return ['success' => 'play recorded'];
1157
	}
1158
1159
	/**
1160
	 * @AmpacheAPI
1161
	 */
1162
	protected function user_preferences() : array {
1163
		return ['user_preference' => AmpachePreferences::getAll()];
1164
	}
1165
1166
	/**
1167
	 * @AmpacheAPI
1168
	 */
1169
	protected function user_preference(string $filter) : array {
1170
		$pref = AmpachePreferences::get($filter);
1171
		if ($pref === null) {
1172
			throw new AmpacheException("Not Found: $filter", 400);
1173
		} else {
1174
			return ['user_preference' => [$pref]];
1175
		}
1176
	}
1177
1178
	/**
1179
	 * @AmpacheAPI
1180
	 */
1181
	protected function download(int $id, string $type='song') : Response {
1182
		// request param `format` is ignored
1183
		$userId = $this->session->getUserId();
1184
1185
		if ($type === 'song') {
1186
			try {
1187
				$track = $this->trackBusinessLayer->find($id, $userId);
1188
			} catch (BusinessLayerException $e) {
1189
				return new ErrorResponse(Http::STATUS_NOT_FOUND, $e->getMessage());
1190
			}
1191
1192
			$file = $this->librarySettings->getFolder($userId)->getById($track->getFileId())[0] ?? null;
1193
1194
			if ($file instanceof \OCP\Files\File) {
1195
				return new FileStreamResponse($file);
1196
			} else {
1197
				return new ErrorResponse(Http::STATUS_NOT_FOUND);
1198
			}
1199
		} elseif ($type === 'podcast' || $type === 'podcast_episode') { // there's a difference between APIv4 and APIv5
1200
			$episode = $this->podcastEpisodeBusinessLayer->find($id, $userId);
1201
			return new RedirectResponse($episode->getStreamUrl());
1202
		} elseif ($type === 'playlist') {
1203
			$songIds = ($id === self::ALL_TRACKS_PLAYLIST_ID)
1204
				? $this->trackBusinessLayer->findAllIds($userId)
1205
				: $this->playlistBusinessLayer->find($id, $userId)->getTrackIdsAsArray();
1206
			if (empty($songIds)) {
1207
				throw new AmpacheException("The playlist $id is empty", 404);
1208
			} else {
1209
				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

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

1550
		if ($type === 'playlist' || $entity->/** @scrutinizer ignore-call */ getCoverFileId()) {
Loading history...
1551
			$id = $entity->getId();
1552
			$token = $this->imageService->getToken($type, $id, $this->session->getAmpacheUserId());
1553
			return $this->urlGenerator->linkToRouteAbsolute('music.ampacheImage.image') . "?object_type=$type&object_id=$id&token=$token";
1554
		} else {
1555
			return '';
1556
		}
1557
	}
1558
1559
	private static function indexIsWithinOffsetAndLimit(int $index, ?int $offset, ?int $limit) : bool {
1560
		$offset = \intval($offset); // missing offset is interpreted as 0-offset
1561
		return ($limit === null) || ($index >= $offset && $index < $offset + $limit);
1562
	}
1563
1564
	private function prefixAndBaseName(?string $name) : array {
1565
		$parts = ['prefix' => null, 'basename' => $name];
1566
1567
		if ($name !== null) {
1568
			foreach ($this->namePrefixes as $prefix) {
1569
				if (Util::startsWith($name, $prefix . ' ', /*ignoreCase=*/true)) {
1570
					$parts['prefix'] = $prefix;
1571
					$parts['basename'] = \substr($name, \strlen($prefix) + 1);
1572
					break;
1573
				}
1574
			}
1575
		}
1576
1577
		return $parts;
1578
	}
1579
1580
	private function renderAlbumOrArtistRef(int $id, string $name) : array {
1581
		if ($this->apiMajorVersion() > 5) {
1582
			return [
1583
				'id' => (string)$id,
1584
				'name' => $name,
1585
			] + $this->prefixAndBaseName($name);
1586
		} else {
1587
			return [
1588
				'id' => (string)$id,
1589
				'text' => $name
1590
			];
1591
		}
1592
	}
1593
1594
	/**
1595
	 * @param Artist[] $artists
1596
	 */
1597
	private function renderArtists(array $artists) : array {
1598
		$userId = $this->session->getUserId();
1599
		$genreMap = Util::createIdLookupTable($this->genreBusinessLayer->findAll($userId));
1600
		$genreKey = $this->genreKey();
1601
1602
		return [
1603
			'artist' => \array_map(function (Artist $artist) use ($userId, $genreMap, $genreKey) {
1604
				$albumCount = $this->albumBusinessLayer->countByAlbumArtist($artist->getId());
1605
				$songCount = $this->trackBusinessLayer->countByArtist($artist->getId());
1606
				$name = $artist->getNameString($this->l10n);
1607
				$nameParts = $this->prefixAndBaseName($name);
1608
				return [
1609
					'id' => (string)$artist->getId(),
1610
					'name' => $name,
1611
					'prefix' => $nameParts['prefix'],
1612
					'basename' => $nameParts['basename'],
1613
					'albums' => $albumCount, // TODO: this should contain objects if requested; in API5+, this never contains the count
1614
					'albumcount' => $albumCount,
1615
					'songs' => $songCount, // TODO: this should contain objects if requested; in API5+, this never contains the count
1616
					'songcount' => $songCount,
1617
					'time' => $this->trackBusinessLayer->totalDurationByArtist($artist->getId()),
1618
					'art' => $this->createCoverUrl($artist),
1619
					'rating' => $artist->getRating() ?? 0,
1620
					'preciserating' => $artist->getRating() ?? 0,
1621
					'flag' => !empty($artist->getStarred()),
1622
					$genreKey => \array_map(function ($genreId) use ($genreMap) {
1623
						return [
1624
							'id' => (string)$genreId,
1625
							'text' => $genreMap[$genreId]->getNameString($this->l10n),
1626
							'count' => 1
1627
						];
1628
					}, $this->trackBusinessLayer->getGenresByArtistId($artist->getId(), $userId))
1629
				];
1630
			}, $artists)
1631
		];
1632
	}
1633
1634
	/**
1635
	 * @param Album[] $albums
1636
	 */
1637
	private function renderAlbums(array $albums) : array {
1638
		$genreKey = $this->genreKey();
1639
		$apiMajor = $this->apiMajorVersion();
1640
		// In APIv6 JSON format, there is a new property `artists` with an array value
1641
		$includeArtists = ($this->jsonMode && $apiMajor > 5);
1642
		// In APIv3-4, the property 'tracks' was used for the song count in case the inclusion of songs wasn't requested.
1643
		// APIv5+ has the property 'songcount' for this and 'tracks' may only contain objects.
1644
		$tracksMayDenoteCount = ($apiMajor < 5); 
1645
1646
		return [
1647
			'album' => \array_map(function (Album $album) use ($genreKey, $includeArtists, $tracksMayDenoteCount) {
1648
				$name = $album->getNameString($this->l10n);
1649
				$nameParts = $this->prefixAndBaseName($name);
1650
				$songCount = $this->trackBusinessLayer->countByAlbum($album->getId());
1651
				$songs = $album->getTracks();
1652
1653
				$apiAlbum = [
1654
					'id' => (string)$album->getId(),
1655
					'name' => $name,
1656
					'prefix' => $nameParts['prefix'],
1657
					'basename' => $nameParts['basename'],
1658
					'artist' => $this->renderAlbumOrArtistRef(
1659
						$album->getAlbumArtistId(),
1660
						$album->getAlbumArtistNameString($this->l10n)
1661
					),
1662
					'tracks' => ($songs !== null) ? $this->renderSongs($songs, false) : ($tracksMayDenoteCount ? $songCount : null),
1663
					'songcount' => $songCount,
1664
					'diskcount' => $album->getNumberOfDisks(),
1665
					'time' => $this->trackBusinessLayer->totalDurationOfAlbum($album->getId()),
1666
					'rating' => $album->getRating() ?? 0,
1667
					'preciserating' => $album->getRating() ?? 0,
1668
					'year' => $album->yearToAPI(),
1669
					'art' => $this->createCoverUrl($album),
1670
					'flag' => !empty($album->getStarred()),
1671
					$genreKey => \array_map(function ($genre) {
1672
						return [
1673
							'id' => (string)$genre->getId(),
1674
							'text' => $genre->getNameString($this->l10n),
1675
							'count' => 1
1676
						];
1677
					}, $album->getGenres() ?? [])
1678
				];
1679
				if ($includeArtists) {
1680
					$apiAlbum['artists'] = [$apiAlbum['artist']];
1681
				}
1682
				if ($this->jsonMode && $songs !== null) {
1683
					// Remove an unnecessary level on the JSON API
1684
					$apiAlbum['tracks'] = $apiAlbum['tracks']['song'];
1685
				}
1686
1687
				return $apiAlbum;
1688
			}, $albums)
1689
		];
1690
	}
1691
1692
	/**
1693
	 * @param Track[] $tracks
1694
	 */
1695
	private function renderSongs(array $tracks, bool $injectAlbums=true) : array {
1696
		if ($injectAlbums) {
1697
			$userId = $this->session->getUserId();
1698
			$this->albumBusinessLayer->injectAlbumsToTracks($tracks, $userId);
1699
		}
1700
1701
		$createPlayUrl = function(Track $track) : string {
1702
			return $this->createAmpacheActionUrl('download', $track->getId());
1703
		};
1704
		$createImageUrl = function(Track $track) : string {
1705
			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

1705
			return $this->createCoverUrl(/** @scrutinizer ignore-type */ $track->getAlbum());
Loading history...
1706
		};
1707
		$renderRef = function(int $id, string $name) : array {
1708
			return $this->renderAlbumOrArtistRef($id, $name);
1709
		};
1710
		$genreKey = $this->genreKey();
1711
		// In APIv6 JSON format, there is a new property `artists` with an array value
1712
		$includeArtists = ($this->jsonMode && $this->apiMajorVersion() > 5);
1713
1714
		return [
1715
			'song' => Util::arrayMapMethod($tracks, 'toAmpacheApi', 
1716
				[$this->l10n, $createPlayUrl, $createImageUrl, $renderRef, $genreKey, $includeArtists])
1717
		];
1718
	}
1719
1720
	/**
1721
	 * @param Playlist[] $playlists
1722
	 */
1723
	private function renderPlaylists(array $playlists) : array {
1724
		$createImageUrl = function(Playlist $playlist) : string {
1725
			if ($playlist->getId() === self::ALL_TRACKS_PLAYLIST_ID) {
1726
				return '';
1727
			} else {
1728
				return $this->createCoverUrl($playlist);
1729
			}
1730
		};
1731
1732
		return [
1733
			'playlist' => Util::arrayMapMethod($playlists, 'toAmpacheApi', [$createImageUrl])
1734
		];
1735
	}
1736
1737
	/**
1738
	 * @param PodcastChannel[] $channels
1739
	 */
1740
	private function renderPodcastChannels(array $channels) : array {
1741
		return [
1742
			'podcast' => Util::arrayMapMethod($channels, 'toAmpacheApi')
1743
		];
1744
	}
1745
1746
	/**
1747
	 * @param PodcastEpisode[] $episodes
1748
	 */
1749
	private function renderPodcastEpisodes(array $episodes) : array {
1750
		return [
1751
			'podcast_episode' => Util::arrayMapMethod($episodes, 'toAmpacheApi')
1752
		];
1753
	}
1754
1755
	/**
1756
	 * @param RadioStation[] $stations
1757
	 */
1758
	private function renderLiveStreams(array $stations) : array {
1759
		return [
1760
			'live_stream' => Util::arrayMapMethod($stations, 'toAmpacheApi')
1761
		];
1762
	}
1763
1764
	/**
1765
	 * @param Genre[] $genres
1766
	 */
1767
	private function renderTags(array $genres) : array {
1768
		return [
1769
			'tag' => Util::arrayMapMethod($genres, 'toAmpacheApi', [$this->l10n])
1770
		];
1771
	}
1772
1773
	/**
1774
	 * @param Genre[] $genres
1775
	 */
1776
	private function renderGenres(array $genres) : array {
1777
		return [
1778
			'genre' => Util::arrayMapMethod($genres, 'toAmpacheApi', [$this->l10n])
1779
		];
1780
	}
1781
1782
	/**
1783
	 * @param Bookmark[] $bookmarks
1784
	 */
1785
	private function renderBookmarks(array $bookmarks) : array {
1786
		return [
1787
			'bookmark' => Util::arrayMapMethod($bookmarks, 'toAmpacheApi')
1788
		];
1789
	}
1790
1791
	/**
1792
	 * @param Track[] $tracks
1793
	 */
1794
	private function renderSongsIndex(array $tracks) : array {
1795
		return [
1796
			'song' => \array_map(function ($track) {
1797
				return [
1798
					'id' => (string)$track->getId(),
1799
					'title' => $track->getTitle(),
1800
					'name' => $track->getTitle(),
1801
					'artist' => $this->renderAlbumOrArtistRef($track->getArtistId(), $track->getArtistNameString($this->l10n)),
1802
					'album' => $this->renderAlbumOrArtistRef($track->getAlbumId(), $track->getAlbumNameString($this->l10n))
1803
				];
1804
			}, $tracks)
1805
		];
1806
	}
1807
1808
	/**
1809
	 * @param Album[] $albums
1810
	 */
1811
	private function renderAlbumsIndex(array $albums) : array {
1812
		return [
1813
			'album' => \array_map(function ($album) {
1814
				$name = $album->getNameString($this->l10n);
1815
				$nameParts = $this->prefixAndBaseName($name);
1816
1817
				return [
1818
					'id' => (string)$album->getId(),
1819
					'name' => $name,
1820
					'prefix' => $nameParts['prefix'],
1821
					'basename' => $nameParts['basename'],
1822
					'artist' => $this->renderAlbumOrArtistRef($album->getAlbumArtistId(), $album->getAlbumArtistNameString($this->l10n))
1823
				];
1824
			}, $albums)
1825
		];
1826
	}
1827
1828
	/**
1829
	 * @param Artist[] $artists
1830
	 */
1831
	private function renderArtistsIndex(array $artists) : array {
1832
		return [
1833
			'artist' => \array_map(function ($artist) {
1834
				$userId = $this->session->getUserId();
1835
				$albums = $this->albumBusinessLayer->findAllByArtist($artist->getId(), $userId);
1836
				$name = $artist->getNameString($this->l10n);
1837
				$nameParts = $this->prefixAndBaseName($name);
1838
1839
				return [
1840
					'id' => (string)$artist->getId(),
1841
					'name' => $name,
1842
					'prefix' => $nameParts['prefix'],
1843
					'basename' => $nameParts['basename'],
1844
					'album' => \array_map(function ($album) {
1845
						return $this->renderAlbumOrArtistRef($album->getId(), $album->getNameString($this->l10n));
1846
					}, $albums)
1847
				];
1848
			}, $artists)
1849
		];
1850
	}
1851
1852
	/**
1853
	 * @param Playlist[] $playlists
1854
	 */
1855
	private function renderPlaylistsIndex(array $playlists) : array {
1856
		return [
1857
			'playlist' => \array_map(function ($playlist) {
1858
				return [
1859
					'id' => (string)$playlist->getId(),
1860
					'name' => $playlist->getName(),
1861
					'playlisttrack' => $playlist->getTrackIdsAsArray()
1862
				];
1863
			}, $playlists)
1864
		];
1865
	}
1866
1867
	/**
1868
	 * @param PodcastChannel[] $channels
1869
	 */
1870
	private function renderPodcastChannelsIndex(array $channels) : array {
1871
		// The v4 API spec does not give any examples of this, and the v5 example is almost identical to the v4 "normal" result
1872
		return $this->renderPodcastChannels($channels);
1873
	}
1874
1875
	/**
1876
	 * @param PodcastEpisode[] $episodes
1877
	 */
1878
	private function renderPodcastEpisodesIndex(array $episodes) : array {
1879
		// The v4 API spec does not give any examples of this, and the v5 example is almost identical to the v4 "normal" result
1880
		return $this->renderPodcastEpisodes($episodes);
1881
	}
1882
1883
	/**
1884
	 * @param RadioStation[] $stations
1885
	 */
1886
	private function renderLiveStreamsIndex(array $stations) : array {
1887
		// The API spec gives no examples of this, but testing with Ampache demo server revealed that the format is indentical to the "full" format
1888
		return $this->renderLiveStreams($stations);
1889
	}
1890
1891
	/**
1892
	 * @param Entity[] $entities
1893
	 */
1894
	private function renderEntitiesList($entities) : array {
1895
		return [
1896
			'list' => \array_map(function ($entity) {
1897
				$name = $entity->getNameString($this->l10n);
1898
				return [
1899
					'id' => (string)$entity->getId(),
1900
					'name' => $name
1901
				] + $this->prefixAndBaseName($name);
1902
			}, $entities)
1903
		];
1904
	}
1905
1906
	/**
1907
	 * @param Entity[] $entities
1908
	 */
1909
	private function renderEntityIds(array $entities) : array {
1910
		return ['id' => Util::extractIds($entities)];
1911
	}
1912
1913
	/**
1914
	 * Array is considered to be "indexed" if its first element has numerical key.
1915
	 * Empty array is considered to be "indexed".
1916
	 */
1917
	private static function arrayIsIndexed(array $array) : bool {
1918
		\reset($array);
1919
		return empty($array) || \is_int(\key($array));
1920
	}
1921
1922
	/**
1923
	 * The JSON API has some asymmetries with the XML API. This function makes the needed
1924
	 * translations for the result content before it is converted into JSON.
1925
	 */
1926
	private function prepareResultForJsonApi(array $content) : array {
1927
		$apiVer = $this->apiMajorVersion();
1928
1929
		// Special handling is needed for responses returning an array of library entities,
1930
		// depending on the API version. In these cases, the outermost array is of associative
1931
		// type with a single value which is a non-associative array.
1932
		if (\count($content) === 1 && !self::arrayIsIndexed($content)
1933
				&& \is_array(\current($content)) && self::arrayIsIndexed(\current($content))) {
1934
			// In API versions < 5, the root node is an anonymous array. Unwrap the outermost array.
1935
			if ($apiVer < 5) {
1936
				$content = \array_pop($content);
1937
			}
1938
			// In later versions, the root object has a named array for plural actions (like "songs", "artists").
1939
			// For singular actions (like "song", "artist"), the root object contains directly the entity properties.
1940
			else {
1941
				$action = $this->request->getParam('action');
1942
				$plural = (\substr($action, -1) === 's' || \in_array($action, ['get_similar', 'advanced_search', 'list']));
1943
1944
				// In APIv5, the action "album" is an excption, it is formatted as if it was a plural action.
1945
				// This outlier has been fixed in APIv6.
1946
				$api5albumOddity = ($apiVer === 5 && $action === 'album');
1947
1948
				// The actions "user_preference" and "system_preference" are another kind of outliers in APIv5,
1949
				// their reponses are anonymou 1-item arrays. This got fixed in the APIv6.0.1
1950
				$api5preferenceOddity = ($apiVer === 5 && Util::endsWith($action, 'preference'));
1951
1952
				if ($api5preferenceOddity) {
1953
					$content = \array_pop($content);
1954
				} elseif (!($plural  || $api5albumOddity)) {
1955
					$content = \array_pop($content);
1956
					$content = \array_pop($content);
1957
				}
1958
			}
1959
		}
1960
1961
		// In API versions < 6, all boolean valued properties should be converted to 0/1.
1962
		if ($apiVer < 6) {
1963
			Util::intCastArrayValues($content, 'is_bool');
1964
		}
1965
1966
		// The key 'text' has a special meaning on XML responses, as it makes the corresponding value
1967
		// to be treated as text content of the parent element. In the JSON API, these are mostly
1968
		// substituted with property 'name', but error responses use the property 'message', instead.
1969
		if (\array_key_exists('error', $content)) {
1970
			$content = Util::convertArrayKeys($content, ['text' => 'message']);
1971
		} else {
1972
			$content = Util::convertArrayKeys($content, ['text' => 'name']);
1973
		}
1974
		return $content;
1975
	}
1976
1977
	/**
1978
	 * The XML API has some asymmetries with the JSON API. This function makes the needed
1979
	 * translations for the result content before it is converted into XML.
1980
	 */
1981
	private function prepareResultForXmlApi(array $content) : array {
1982
		\reset($content);
1983
		$firstKey = \key($content);
1984
1985
		// all 'entity list' kind of responses shall have the (deprecated) total_count element
1986
		if ($firstKey == 'song' || $firstKey == 'album' || $firstKey == 'artist' || $firstKey == 'playlist'
1987
				|| $firstKey == 'tag' || $firstKey == 'genre' || $firstKey == 'podcast' || $firstKey == 'podcast_episode'
1988
				|| $firstKey == 'live_stream') {
1989
			$content = ['total_count' => \count($content[$firstKey])] + $content;
1990
		}
1991
1992
		// for some bizarre reason, the 'id' arrays have 'index' attributes in the XML format
1993
		if ($firstKey == 'id') {
1994
			$content['id'] = \array_map(function ($id, $index) {
1995
				return ['index' => $index, 'text' => $id];
1996
			}, $content['id'], \array_keys($content['id']));
1997
		}
1998
1999
		return ['root' => $content];
2000
	}
2001
2002
	private function genreKey() : string {
2003
		return ($this->apiMajorVersion() > 4) ? 'genre' : 'tag';
2004
	}
2005
2006
	private function apiMajorVersion() : int {
2007
		// During the handshake, we don't yet have a session but the requeted version may be in the request args
2008
		$verString = ($this->session !== null) 
2009
			? $this->session->getApiVersion()
2010
			: $this->request->getParam('version');
2011
		
2012
		if (\is_string($verString) && \strlen($verString)) {
2013
			$ver = (int)$verString[0];
2014
		} else {
2015
			// Default version is 6 unless otherwise defined in config.php
2016
			$ver = (int)$this->config->getSystemValue('music.ampache_api_default_ver', 6);
2017
		}
2018
2019
		// For now, we have three supported major versions. Major version 3 can be sufficiently supported
2020
		// with our "version 4" implementation.
2021
		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...
2022
	}
2023
2024
	private function apiVersionString() : string {
2025
		switch ($this->apiMajorVersion()) {
2026
			case 4:		return self::API4_VERSION;
2027
			case 5:		return self::API5_VERSION;
2028
			case 6:		return self::API6_VERSION;
2029
			default:	throw new AmpacheException('Unexpected api major version', 500);
2030
		}
2031
	}
2032
2033
	private function mapApiV4ErrorToV5(int $code) : int {
2034
		switch ($code) {
2035
			case 400:	return 4710;	// bad request
2036
			case 401:	return 4701;	// invalid handshake
2037
			case 403:	return 4703;	// access denied
2038
			case 404:	return 4704;	// not found
2039
			case 405:	return 4705;	// missing
2040
			case 412:	return 4742;	// failed access check
2041
			case 501:	return 4700;	// access control not enabled
2042
			default:	return 5000;	// unexcpected (not part of the API spec)
2043
		}
2044
	}
2045
}
2046
2047
/**
2048
 * Adapter class which acts like the Playlist class for the purpose of
2049
 * AmpacheController::renderPlaylists but contains all the track of the user.
2050
 */
2051
class AmpacheController_AllTracksPlaylist extends Playlist {
2052
	private $trackBusinessLayer;
2053
	private $l10n;
2054
2055
	public function __construct(string $userId, TrackBusinessLayer $trackBusinessLayer, IL10N $l10n) {
2056
		$this->userId = $userId;
2057
		$this->trackBusinessLayer = $trackBusinessLayer;
2058
		$this->l10n = $l10n;
2059
	}
2060
2061
	public function getId() : int {
2062
		return AmpacheController::ALL_TRACKS_PLAYLIST_ID;
2063
	}
2064
2065
	public function getName() : string {
2066
		return $this->l10n->t('All tracks');
2067
	}
2068
2069
	public function getTrackCount() : int {
2070
		return $this->trackBusinessLayer->count($this->userId);
2071
	}
2072
}
2073