Passed
Push — feature/909_Ampache_API_improv... ( 150565...b6c69f )
by Pauli
02:50
created

AmpacheController::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 44
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

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

How to fix   Many Parameters   

Many Parameters

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

There are several approaches to avoid long parameter lists:

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

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

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

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

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

1127
			$entity->/** @scrutinizer ignore-call */ 
1128
            setRating($rating);
Loading history...
1128
			$businessLayer->update($entity);
1129
		} else {
1130
			throw new AmpacheException("Unsupported type $type", 400);
1131
		}
1132
1133
		return ['success' => "rating set to $rating for $type $id"];
1134
	}
1135
1136
	/**
1137
	 * @AmpacheAPI
1138
	 */
1139
	protected function record_play(int $id, ?int $date) : array {
1140
		$timeOfPlay = ($date === null) ? null : new \DateTime('@' . $date);
1141
		$this->trackBusinessLayer->recordTrackPlayed($id, $this->session->getUserId(), $timeOfPlay);
1142
		return ['success' => 'play recorded'];
1143
	}
1144
1145
	/**
1146
	 * @AmpacheAPI
1147
	 */
1148
	protected function download(int $id, string $type='song') : Response {
1149
		// request param `format` is ignored
1150
		$userId = $this->session->getUserId();
1151
1152
		if ($type === 'song') {
1153
			try {
1154
				$track = $this->trackBusinessLayer->find($id, $userId);
1155
			} catch (BusinessLayerException $e) {
1156
				return new ErrorResponse(Http::STATUS_NOT_FOUND, $e->getMessage());
1157
			}
1158
1159
			$file = $this->librarySettings->getFolder($userId)->getById($track->getFileId())[0] ?? null;
1160
1161
			if ($file instanceof \OCP\Files\File) {
1162
				return new FileStreamResponse($file);
1163
			} else {
1164
				return new ErrorResponse(Http::STATUS_NOT_FOUND);
1165
			}
1166
		} elseif ($type === 'podcast' || $type === 'podcast_episode') { // there's a difference between APIv4 and APIv5
1167
			$episode = $this->podcastEpisodeBusinessLayer->find($id, $userId);
1168
			return new RedirectResponse($episode->getStreamUrl());
1169
		} elseif ($type === 'playlist') {
1170
			$songIds = ($id === self::ALL_TRACKS_PLAYLIST_ID)
1171
				? $this->trackBusinessLayer->findAllIds($userId)
1172
				: $this->playlistBusinessLayer->find($id, $userId)->getTrackIdsAsArray();
1173
			if (empty($songIds)) {
1174
				throw new AmpacheException("The playlist $id is empty", 404);
1175
			} else {
1176
				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

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

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

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