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

AmpacheController::bookmark_create()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 4
c 0
b 0
f 0
nc 1
nop 4
dl 0
loc 6
rs 10
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