Passed
Push — feature/909_Ampache_API_improv... ( b6c69f...943762 )
by Pauli
04:51 queued 02:07
created

AmpacheController::user_preference()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 6
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

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