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

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

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

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

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

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

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