Passed
Push — master ( f9c9de...599a1b )
by Pauli
01:59
created

SubsonicController::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 44
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

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

How to fix   Many Parameters   

Many Parameters

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

There are several approaches to avoid long parameter lists:

1
<?php declare(strict_types=1);
2
3
/**
4
 * ownCloud - Music app
5
 *
6
 * This file is licensed under the Affero General Public License version 3 or
7
 * later. See the COPYING file.
8
 *
9
 * @author Pauli Järvinen <[email protected]>
10
 * @copyright Pauli Järvinen 2019 - 2021
11
 */
12
13
namespace OCA\Music\Controller;
14
15
use \OCP\AppFramework\Controller;
16
use \OCP\AppFramework\Http\DataDisplayResponse;
17
use \OCP\AppFramework\Http\JSONResponse;
18
use \OCP\AppFramework\Http\RedirectResponse;
19
use \OCP\Files\File;
20
use \OCP\Files\Folder;
21
use \OCP\IRequest;
22
use \OCP\IUserManager;
23
use \OCP\IURLGenerator;
24
25
use \OCA\Music\AppFramework\BusinessLayer\BusinessLayerException;
26
use \OCA\Music\AppFramework\Core\Logger;
27
use \OCA\Music\AppFramework\Utility\MethodAnnotationReader;
28
use \OCA\Music\AppFramework\Utility\RequestParameterExtractor;
29
use \OCA\Music\AppFramework\Utility\RequestParameterExtractorException;
30
31
use \OCA\Music\BusinessLayer\AlbumBusinessLayer;
32
use \OCA\Music\BusinessLayer\ArtistBusinessLayer;
33
use \OCA\Music\BusinessLayer\BookmarkBusinessLayer;
34
use \OCA\Music\BusinessLayer\GenreBusinessLayer;
35
use \OCA\Music\BusinessLayer\Library;
36
use \OCA\Music\BusinessLayer\PlaylistBusinessLayer;
37
use \OCA\Music\BusinessLayer\PodcastChannelBusinessLayer;
38
use \OCA\Music\BusinessLayer\PodcastEpisodeBusinessLayer;
39
use \OCA\Music\BusinessLayer\RadioStationBusinessLayer;
40
use \OCA\Music\BusinessLayer\TrackBusinessLayer;
41
42
use \OCA\Music\Db\Album;
43
use \OCA\Music\Db\Artist;
44
use \OCA\Music\Db\Bookmark;
45
use \OCA\Music\Db\Genre;
46
use \OCA\Music\Db\PodcastEpisode;
47
use \OCA\Music\Db\SortBy;
48
use \OCA\Music\Db\Track;
49
50
use \OCA\Music\Http\FileResponse;
51
use \OCA\Music\Http\FileStreamResponse;
52
use \OCA\Music\Http\XmlResponse;
53
54
use \OCA\Music\Middleware\SubsonicException;
55
56
use \OCA\Music\Utility\CoverHelper;
57
use \OCA\Music\Utility\DetailsHelper;
58
use \OCA\Music\Utility\LastfmService;
59
use \OCA\Music\Utility\PodcastService;
60
use \OCA\Music\Utility\Random;
61
use \OCA\Music\Utility\UserMusicFolder;
62
use \OCA\Music\Utility\Util;
63
64
class SubsonicController extends Controller {
65
	const API_VERSION = '1.16.0';
66
67
	private $albumBusinessLayer;
68
	private $artistBusinessLayer;
69
	private $bookmarkBusinessLayer;
70
	private $genreBusinessLayer;
71
	private $playlistBusinessLayer;
72
	private $podcastChannelBusinessLayer;
73
	private $podcastEpisodeBusinessLayer;
74
	private $radioStationBusinessLayer;
75
	private $trackBusinessLayer;
76
	private $library;
77
	private $urlGenerator;
78
	private $userManager;
79
	private $userMusicFolder;
80
	private $l10n;
81
	private $coverHelper;
82
	private $detailsHelper;
83
	private $lastfmService;
84
	private $podcastService;
85
	private $random;
86
	private $logger;
87
	private $userId;
88
	private $format;
89
	private $callback;
90
91
	public function __construct($appname,
92
								IRequest $request,
93
								$l10n,
94
								IURLGenerator $urlGenerator,
95
								IUserManager $userManager,
96
								AlbumBusinessLayer $albumBusinessLayer,
97
								ArtistBusinessLayer $artistBusinessLayer,
98
								BookmarkBusinessLayer $bookmarkBusinessLayer,
99
								GenreBusinessLayer $genreBusinessLayer,
100
								PlaylistBusinessLayer $playlistBusinessLayer,
101
								PodcastChannelBusinessLayer $podcastChannelBusinessLayer,
102
								PodcastEpisodeBusinessLayer $podcastEpisodeBusinessLayer,
103
								RadioStationBusinessLayer $radioStationBusinessLayer,
104
								TrackBusinessLayer $trackBusinessLayer,
105
								Library $library,
106
								UserMusicFolder $userMusicFolder,
107
								CoverHelper $coverHelper,
108
								DetailsHelper $detailsHelper,
109
								LastfmService $lastfmService,
110
								PodcastService $podcastService,
111
								Random $random,
112
								Logger $logger) {
113
		parent::__construct($appname, $request);
114
115
		$this->albumBusinessLayer = $albumBusinessLayer;
116
		$this->artistBusinessLayer = $artistBusinessLayer;
117
		$this->bookmarkBusinessLayer = $bookmarkBusinessLayer;
118
		$this->genreBusinessLayer = $genreBusinessLayer;
119
		$this->playlistBusinessLayer = $playlistBusinessLayer;
120
		$this->podcastChannelBusinessLayer = $podcastChannelBusinessLayer;
121
		$this->podcastEpisodeBusinessLayer = $podcastEpisodeBusinessLayer;
122
		$this->radioStationBusinessLayer = $radioStationBusinessLayer;
123
		$this->trackBusinessLayer = $trackBusinessLayer;
124
		$this->library = $library;
125
		$this->urlGenerator = $urlGenerator;
126
		$this->userManager = $userManager;
127
		$this->l10n = $l10n;
128
		$this->userMusicFolder = $userMusicFolder;
129
		$this->coverHelper = $coverHelper;
130
		$this->detailsHelper = $detailsHelper;
131
		$this->lastfmService = $lastfmService;
132
		$this->podcastService = $podcastService;
133
		$this->random = $random;
134
		$this->logger = $logger;
135
	}
136
137
	/**
138
	 * Called by the middleware to set the reponse format to be used
139
	 * @param string $format Response format: xml/json/jsonp
140
	 * @param string|null $callback Function name to use if the @a $format is 'jsonp'
141
	 */
142
	public function setResponseFormat(string $format, string $callback = null) {
143
		$this->format = $format;
144
		$this->callback = $callback;
145
	}
146
147
	/**
148
	 * Called by the middleware once the user credentials have been checked
149
	 * @param string $userId
150
	 */
151
	public function setAuthenticatedUser(string $userId) {
152
		$this->userId = $userId;
153
	}
154
155
	/**
156
	 * @NoAdminRequired
157
	 * @PublicPage
158
	 * @NoCSRFRequired
159
	 */
160
	public function handleRequest($method) {
161
		$this->logger->log("Subsonic request $method", 'debug');
162
163
		// Allow calling all methods with or without the postfix ".view"
164
		if (Util::endsWith($method, ".view")) {
165
			$method = \substr($method, 0, -\strlen(".view"));
166
		}
167
168
		// Allow calling any functions annotated to be part of the API
169
		if (\method_exists($this, $method)) {
170
			$annotationReader = new MethodAnnotationReader($this, $method);
171
			if ($annotationReader->hasAnnotation('SubsonicAPI')) {
172
				$parameterExtractor = new RequestParameterExtractor($this->request);
173
				try {
174
					$parameterValues = $parameterExtractor->getParametersForMethod($this, $method);
175
				} catch (RequestParameterExtractorException $ex) {
176
					return $this->subsonicErrorResponse(10, $ex->getMessage());
177
				}
178
				return \call_user_func_array([$this, $method], $parameterValues);
179
			}
180
		}
181
182
		$this->logger->log("Request $method not supported", 'warn');
183
		return $this->subsonicErrorResponse(70, "Requested action $method is not supported");
184
	}
185
186
	/* -------------------------------------------------------------------------
187
	 * REST API methods
188
	 *------------------------------------------------------------------------*/
189
190
	/**
191
	 * @SubsonicAPI
192
	 */
193
	private function ping() {
194
		return $this->subsonicResponse([]);
195
	}
196
197
	/**
198
	 * @SubsonicAPI
199
	 */
200
	private function getLicense() {
201
		return $this->subsonicResponse([
202
			'license' => [
203
				'valid' => 'true'
204
			]
205
		]);
206
	}
207
208
	/**
209
	 * @SubsonicAPI
210
	 */
211
	private function getMusicFolders() {
212
		// Only single root folder is supported
213
		return $this->subsonicResponse([
214
			'musicFolders' => ['musicFolder' => [
215
				['id' => 'artists', 'name' => $this->l10n->t('Artists')],
216
				['id' => 'folders', 'name' => $this->l10n->t('Folders')]
217
			]]
218
		]);
219
	}
220
221
	/**
222
	 * @SubsonicAPI
223
	 */
224
	private function getIndexes(?string $musicFolderId) {
225
		if ($musicFolderId === 'folders') {
226
			return $this->getIndexesForFolders();
227
		} else {
228
			return $this->getIndexesForArtists();
229
		}
230
	}
231
232
	/**
233
	 * @SubsonicAPI
234
	 */
235
	private function getMusicDirectory(string $id) {
236
		if (Util::startsWith($id, 'folder-')) {
237
			return $this->getMusicDirectoryForFolder($id);
238
		} elseif (Util::startsWith($id, 'artist-')) {
239
			return $this->getMusicDirectoryForArtist($id);
240
		} elseif (Util::startsWith($id, 'album-')) {
241
			return $this->getMusicDirectoryForAlbum($id);
242
		} elseif (Util::startsWith($id, 'podcast_channel-')) {
243
			return $this->getMusicDirectoryForPodcastChannel($id);
244
		} else {
245
			throw new SubsonicException("Unsupported id format $id");
246
		}
247
	}
248
249
	/**
250
	 * @SubsonicAPI
251
	 */
252
	private function getAlbumList(
253
			string $type, ?string $genre, ?int $fromYear, ?int $toYear, int $size=10, int $offset=0) {
254
		$albums = $this->albumsForGetAlbumList($type, $genre, $fromYear, $toYear, $size, $offset);
255
		return $this->subsonicResponse(['albumList' =>
256
				['album' => \array_map([$this, 'albumToOldApi'], $albums)]
257
		]);
258
	}
259
260
	/**
261
	 * @SubsonicAPI
262
	 */
263
	private function getAlbumList2(
264
			string $type, ?string $genre, ?int $fromYear, ?int $toYear, int $size=10, int $offset=0) {
265
		/*
266
		 * According to the API specification, the difference between this and getAlbumList
267
		 * should be that this function would organize albums according the metadata while
268
		 * getAlbumList would organize them by folders. However, we organize by metadata
269
		 * also in getAlbumList, because that's more natural for the Music app and many/most
270
		 * clients do not support getAlbumList2.
271
		 */
272
		$albums = $this->albumsForGetAlbumList($type, $genre, $fromYear, $toYear, $size, $offset);
273
		return $this->subsonicResponse(['albumList2' =>
274
				['album' => \array_map([$this, 'albumToNewApi'], $albums)]
275
		]);
276
	}
277
278
	/**
279
	 * @SubsonicAPI
280
	 */
281
	private function getArtists() {
282
		return $this->getIndexesForArtists('artists');
283
	}
284
285
	/**
286
	 * @SubsonicAPI
287
	 */
288
	private function getArtist(string $id) {
289
		$artistId = self::ripIdPrefix($id); // get rid of 'artist-' prefix
290
291
		$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
292
		$albums = $this->albumBusinessLayer->findAllByArtist($artistId, $this->userId);
293
294
		$artistNode = $this->artistToApi($artist);
295
		$artistNode['album'] = \array_map([$this, 'albumToNewApi'], $albums);
296
297
		return $this->subsonicResponse(['artist' => $artistNode]);
298
	}
299
300
	/**
301
	 * @SubsonicAPI
302
	 */
303
	private function getArtistInfo(string $id, bool $includeNotPresent=false) {
304
		return $this->doGetArtistInfo('artistInfo', $id, $includeNotPresent);
305
	}
306
307
	/**
308
	 * @SubsonicAPI
309
	 */
310
	private function getArtistInfo2(string $id, bool $includeNotPresent=false) {
311
		return $this->doGetArtistInfo('artistInfo2', $id, $includeNotPresent);
312
	}
313
314
	/**
315
	 * @SubsonicAPI
316
	 */
317
	private function getAlbumInfo(string $id) {
318
		return $this->doGetAlbumInfo('albumInfo', $id);
319
	}
320
321
	/**
322
	 * @SubsonicAPI
323
	 */
324
	private function getAlbumInfo2(string $id) {
325
		return $this->doGetAlbumInfo('albumInfo2', $id);
326
	}
327
328
	/**
329
	 * @SubsonicAPI
330
	 */
331
	private function getSimilarSongs(string $id, int $count=50) {
332
		return $this->doGetSimilarSongs('similarSongs', $id, $count);
333
	}
334
335
	/**
336
	 * @SubsonicAPI
337
	 */
338
	private function getSimilarSongs2(string $id, int $count=50) {
339
		return $this->doGetSimilarSongs('similarSongs2', $id, $count);
340
	}
341
342
	/**
343
	 * @SubsonicAPI
344
	 */
345
	private function getTopSongs(string $artist, int $count=50) {
346
		$tracks = $this->lastfmService->getTopTracks($artist, $this->userId, $count);
347
		return $this->subsonicResponse(['topSongs' =>
348
			['song' => $this->tracksToApi($tracks)]
349
		]);
350
	}
351
352
	/**
353
	 * @SubsonicAPI
354
	 */
355
	private function getAlbum(string $id) {
356
		$albumId = self::ripIdPrefix($id); // get rid of 'album-' prefix
357
358
		$album = $this->albumBusinessLayer->find($albumId, $this->userId);
359
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $this->userId);
360
361
		$albumNode = $this->albumToNewApi($album);
362
		$albumNode['song'] = \array_map(function ($track) use ($album) {
363
			$track->setAlbum($album);
364
			return $track->toSubsonicApi($this->l10n);
365
		}, $tracks);
366
		return $this->subsonicResponse(['album' => $albumNode]);
367
	}
368
369
	/**
370
	 * @SubsonicAPI
371
	 */
372
	private function getSong(string $id) {
373
		$trackId = self::ripIdPrefix($id); // get rid of 'track-' prefix
374
		$track = $this->trackBusinessLayer->find($trackId, $this->userId);
375
		$track->setAlbum($this->albumBusinessLayer->find($track->getAlbumId(), $this->userId));
376
377
		return $this->subsonicResponse(['song' => $track->toSubsonicApi($this->l10n)]);
378
	}
379
380
	/**
381
	 * @SubsonicAPI
382
	 */
383
	private function getRandomSongs(?string $genre, ?string $fromYear, ?string $toYear, int $size=10) {
384
		$size = \min($size, 500); // the API spec limits the maximum amount to 500
385
386
		if ($genre !== null) {
387
			$trackPool = $this->findTracksByGenre($genre);
388
		} else {
389
			$trackPool = $this->trackBusinessLayer->findAll($this->userId);
390
		}
391
392
		if ($fromYear !== null) {
393
			$trackPool = \array_filter($trackPool, function ($track) use ($fromYear) {
394
				return ($track->getYear() !== null && $track->getYear() >= $fromYear);
395
			});
396
		}
397
398
		if ($toYear !== null) {
399
			$trackPool = \array_filter($trackPool, function ($track) use ($toYear) {
400
				return ($track->getYear() !== null && $track->getYear() <= $toYear);
401
			});
402
		}
403
404
		$tracks = Random::pickItems($trackPool, $size);
405
406
		return $this->subsonicResponse(['randomSongs' =>
407
				['song' => $this->tracksToApi($tracks)]
408
		]);
409
	}
410
411
	/**
412
	 * @SubsonicAPI
413
	 */
414
	private function getCoverArt(string $id, ?int $size) {
415
		$idParts = \explode('-', $id);
416
		$type = $idParts[0];
417
		$entityId = (int)($idParts[1]);
418
419
		if ($type == 'album') {
420
			$entity = $this->albumBusinessLayer->find($entityId, $this->userId);
421
		} elseif ($type == 'artist') {
422
			$entity = $this->artistBusinessLayer->find($entityId, $this->userId);
423
		} elseif ($type == 'podcast_channel') {
424
			$entity = $this->podcastService->getChannel($entityId, $this->userId, /*$includeEpisodes=*/ false);
425
		} elseif ($type == 'pl') {
426
			$entity = $this->playlistBusinessLayer->find($entityId, $this->userId);
427
		}
428
429
		if (!empty($entity)) {
430
			$rootFolder = $this->userMusicFolder->getFolder($this->userId);
431
			$coverData = $this->coverHelper->getCover($entity, $this->userId, $rootFolder, $size);
432
433
			if ($coverData !== null) {
434
				return new FileResponse($coverData);
435
			}
436
		}
437
438
		return $this->subsonicErrorResponse(70, "entity $id has no cover");
439
	}
440
441
	/**
442
	 * @SubsonicAPI
443
	 */
444
	private function getLyrics(?string $artist, ?string $title) {
445
		$matches = $this->trackBusinessLayer->findAllByNameAndArtistName($title, $artist, $this->userId);
446
		$matchingCount = \count($matches);
447
448
		if ($matchingCount === 0) {
449
			$this->logger->log("No matching track for title '$title' and artist '$artist'", 'debug');
450
			return $this->subsonicResponse(['lyrics' => new \stdClass]);
451
		} else {
452
			if ($matchingCount > 1) {
453
				$this->logger->log("Found $matchingCount tracks matching title ".
454
								"'$title' and artist '$artist'; using the first", 'debug');
455
			}
456
			$track = $matches[0];
457
458
			$artistObj = $this->artistBusinessLayer->find($track->getArtistId(), $this->userId);
459
			$rootFolder = $this->userMusicFolder->getFolder($this->userId);
460
			$lyrics = $this->detailsHelper->getLyrics($track->getFileId(), $rootFolder);
461
462
			return $this->subsonicResponse(['lyrics' => [
463
					'artist' => $artistObj->getNameString($this->l10n),
464
					'title' => $track->getTitle(),
465
					'value' => $lyrics
466
			]]);
467
		}
468
	}
469
470
	/**
471
	 * @SubsonicAPI
472
	 */
473
	private function stream(string $id) {
474
		// We don't support transcaoding, so 'stream' and 'download' act identically
475
		return $this->download($id);
476
	}
477
478
	/**
479
	 * @SubsonicAPI
480
	 */
481
	private function download(string $id) {
482
		$idParts = \explode('-', $id);
483
		$type = $idParts[0];
484
		$entityId = (int)($idParts[1]);
485
486
		if ($type === 'track') {
487
			$track = $this->trackBusinessLayer->find($entityId, $this->userId);
488
			$file = $this->getFilesystemNode($track->getFileId());
489
490
			if ($file instanceof File) {
491
				return new FileStreamResponse($file);
492
			} else {
493
				return $this->subsonicErrorResponse(70, 'file not found');
494
			}
495
		} elseif ($type === 'podcast_episode') {
496
			$episode = $this->podcastService->getEpisode($entityId, $this->userId);
497
			if ($episode instanceof PodcastEpisode) {
498
				return new RedirectResponse($episode->getStreamUrl());
499
			} else {
500
				return $this->subsonicErrorResponse(70, 'episode not found');
501
			}
502
		} else {
503
			return $this->subsonicErrorResponse(0, "id of type $type not supported");
504
		}
505
	}
506
507
	/**
508
	 * @SubsonicAPI
509
	 */
510
	private function search2(string $query, int $artistCount=20, int $artistOffset=0,
511
			int $albumCount=20, int $albumOffset=0, int $songCount=20, int $songOffset=0) {
512
		$results = $this->doSearch($query, $artistCount, $artistOffset, $albumCount, $albumOffset, $songCount, $songOffset);
513
		return $this->searchResponse('searchResult2', $results, /*$useNewApi=*/false);
514
	}
515
516
	/**
517
	 * @SubsonicAPI
518
	 */
519
	private function search3(string $query, int $artistCount=20, int $artistOffset=0,
520
			int $albumCount=20, int $albumOffset=0, int $songCount=20, int $songOffset=0) {
521
		$results = $this->doSearch($query, $artistCount, $artistOffset, $albumCount, $albumOffset, $songCount, $songOffset);
522
		return $this->searchResponse('searchResult3', $results, /*$useNewApi=*/true);
523
	}
524
525
	/**
526
	 * @SubsonicAPI
527
	 */
528
	private function getGenres() {
529
		$genres = $this->genreBusinessLayer->findAll($this->userId, SortBy::Name);
530
531
		return $this->subsonicResponse(['genres' =>
532
			[
533
				'genre' => \array_map(function ($genre) {
534
					return [
535
						'songCount' => $genre->getTrackCount(),
536
						'albumCount' => $genre->getAlbumCount(),
537
						'value' => $genre->getNameString($this->l10n)
538
					];
539
				},
540
				$genres)
541
			]
542
		]);
543
	}
544
545
	/**
546
	 * @SubsonicAPI
547
	 */
548
	private function getSongsByGenre(string $genre, int $count=10, int $offset=0) {
549
		$tracks = $this->findTracksByGenre($genre, $count, $offset);
550
551
		return $this->subsonicResponse(['songsByGenre' =>
552
			['song' => $this->tracksToApi($tracks)]
553
		]);
554
	}
555
556
	/**
557
	 * @SubsonicAPI
558
	 */
559
	private function getPlaylists() {
560
		$playlists = $this->playlistBusinessLayer->findAll($this->userId);
561
562
		foreach ($playlists as &$playlist) {
563
			$playlist->setDuration($this->playlistBusinessLayer->getDuration($playlist->getId(), $this->userId));
564
		}
565
566
		return $this->subsonicResponse(['playlists' =>
567
			['playlist' => Util::arrayMapMethod($playlists, 'toSubsonicApi')]
568
		]);
569
	}
570
571
	/**
572
	 * @SubsonicAPI
573
	 */
574
	private function getPlaylist(int $id) {
575
		$playlist = $this->playlistBusinessLayer->find($id, $this->userId);
576
		$tracks = $this->playlistBusinessLayer->getPlaylistTracks($id, $this->userId);
577
		$playlist->setDuration(\array_reduce($tracks, function (?int $accuDuration, Track $track) : int {
578
			return (int)$accuDuration + (int)$track->getLength();
579
		}));
580
581
		$playlistNode = $playlist->toSubsonicApi();
582
		$playlistNode['entry'] = $this->tracksToApi($tracks);
583
584
		return $this->subsonicResponse(['playlist' => $playlistNode]);
585
	}
586
587
	/**
588
	 * @SubsonicAPI
589
	 */
590
	private function createPlaylist(?string $name, ?string $playlistId, array $songId) {
591
		$songIds = \array_map('self::ripIdPrefix', $songId);
592
593
		// If playlist ID has been passed, then this method actually updates an existing list instead of creating a new one.
594
		// The updating can't be used to rename the list, even if both ID and name are given (this is how the real Subsonic works, too).
595
		if (!empty($playlistId)) {
596
			$playlist = $this->playlistBusinessLayer->find((int)$playlistId, $this->userId);
597
		} elseif (!empty($name)) {
598
			$playlist = $this->playlistBusinessLayer->create($name, $this->userId);
599
		} else {
600
			throw new SubsonicException('Playlist ID or name must be specified.', 10);
601
		}
602
603
		$playlist->setTrackIdsFromArray($songIds);
604
		$this->playlistBusinessLayer->update($playlist);
605
606
		return $this->getPlaylist($playlist->getId());
607
	}
608
609
	/**
610
	 * @SubsonicAPI
611
	 */
612
	private function updatePlaylist(int $playlistId, ?string $name, ?string $comment, array $songIdToAdd, array $songIndexToRemove) {
613
		$songIdsToAdd = \array_map('self::ripIdPrefix', $songIdToAdd);
614
		$songIndicesToRemove = \array_map('intval', $songIndexToRemove);
615
616
		if (!empty($name)) {
617
			$this->playlistBusinessLayer->rename($name, $playlistId, $this->userId);
618
		}
619
620
		if ($comment!== null) {
621
			$this->playlistBusinessLayer->setComment($comment, $playlistId, $this->userId);
622
		}
623
624
		if (!empty($songIndicesToRemove)) {
625
			$this->playlistBusinessLayer->removeTracks($songIndicesToRemove, $playlistId, $this->userId);
626
		}
627
628
		if (!empty($songIdsToAdd)) {
629
			$this->playlistBusinessLayer->addTracks($songIdsToAdd, $playlistId, $this->userId);
630
		}
631
632
		return $this->subsonicResponse([]);
633
	}
634
635
	/**
636
	 * @SubsonicAPI
637
	 */
638
	private function deletePlaylist(int $id) {
639
		$this->playlistBusinessLayer->delete($id, $this->userId);
640
		return $this->subsonicResponse([]);
641
	}
642
643
	/**
644
	 * @SubsonicAPI
645
	 */
646
	private function getInternetRadioStations() {
647
		$stations = $this->radioStationBusinessLayer->findAll($this->userId);
648
649
		return $this->subsonicResponse(['internetRadioStations' =>
650
				['internetRadioStation' => \array_map(function($station) {
651
					return [
652
						'id' => $station->getId(),
653
						'name' => $station->getName(),
654
						'streamUrl' => $station->getStreamUrl(),
655
						'homePageUrl' => $station->getHomeUrl()
656
					];
657
				}, $stations)]
658
		]);
659
	}
660
661
	/**
662
	 * @SubsonicAPI
663
	 */
664
	private function createInternetRadioStation(string $streamUrl, string $name, ?string $homepageUrl) {
665
		$this->radioStationBusinessLayer->create($this->userId, $name, $streamUrl, $homepageUrl);
666
		return $this->subsonicResponse([]);
667
	}
668
669
	/**
670
	 * @SubsonicAPI
671
	 */
672
	private function updateInternetRadioStation(int $id, string $streamUrl, string $name, ?string $homepageUrl) {
673
		$station = $this->radioStationBusinessLayer->find($id, $this->userId);
674
		$station->setStreamUrl($streamUrl);
675
		$station->setName($name);
676
		$station->setHomeUrl($homepageUrl);
677
		$this->radioStationBusinessLayer->update($station);
678
		return $this->subsonicResponse([]);
679
	}
680
681
	/**
682
	 * @SubsonicAPI
683
	 */
684
	private function deleteInternetRadioStation(int $id) {
685
		$this->radioStationBusinessLayer->delete($id, $this->userId);
686
		return $this->subsonicResponse([]);
687
	}
688
689
	/**
690
	 * @SubsonicAPI
691
	 */
692
	private function getUser(string $username) {
693
		if ($username != $this->userId) {
694
			throw new SubsonicException("{$this->userId} is not authorized to get details for other users.", 50);
695
		}
696
697
		return $this->subsonicResponse([
698
			'user' => [
699
				'username' => $username,
700
				'email' => '',
701
				'scrobblingEnabled' => false,
702
				'adminRole' => false,
703
				'settingsRole' => false,
704
				'downloadRole' => true,
705
				'uploadRole' => false,
706
				'playlistRole' => true,
707
				'coverArtRole' => false,
708
				'commentRole' => false,
709
				'podcastRole' => true,
710
				'streamRole' => true,
711
				'jukeboxRole' => false,
712
				'shareRole' => false,
713
				'videoConversionRole' => false,
714
				'folder' => ['artists', 'folders'],
715
			]
716
		]);
717
	}
718
719
	/**
720
	 * @SubsonicAPI
721
	 */
722
	private function getUsers() {
723
		throw new SubsonicException("{$this->userId} is not authorized to get details for other users.", 50);
724
	}
725
726
	/**
727
	 * @SubsonicAPI
728
	 */
729
	private function getAvatar(string $username) {
730
		if ($username != $this->userId) {
731
			throw new SubsonicException("{$this->userId} is not authorized to get avatar for other users.", 50);
732
		}
733
734
		$image = $this->userManager->get($username)->getAvatarImage(150);
735
736
		if ($image !== null) {
737
			return new FileResponse(['content' => $image->data(), 'mimetype' => $image->mimeType()]);
738
		} else {
739
			return $this->subsonicErrorResponse(70, 'user has no avatar');
740
		}
741
	}
742
743
	/**
744
	 * @SubsonicAPI
745
	 */
746
	private function star(array $id, array $albumId, array $artistId) {
747
		$targetIds = self::parseStarringParameters($id, $albumId, $artistId);
748
749
		$this->trackBusinessLayer->setStarred($targetIds['tracks'], $this->userId);
750
		$this->albumBusinessLayer->setStarred($targetIds['albums'], $this->userId);
751
		$this->artistBusinessLayer->setStarred($targetIds['artists'], $this->userId);
752
		$this->podcastChannelBusinessLayer->setStarred($targetIds['podcast_channels'], $this->userId);
753
		$this->podcastEpisodeBusinessLayer->setStarred($targetIds['podcast_episodes'], $this->userId);
754
755
		return $this->subsonicResponse([]);
756
	}
757
758
	/**
759
	 * @SubsonicAPI
760
	 */
761
	private function unstar(array $id, array $albumId, array $artistId) {
762
		$targetIds = self::parseStarringParameters($id, $albumId, $artistId);
763
764
		$this->trackBusinessLayer->unsetStarred($targetIds['tracks'], $this->userId);
765
		$this->albumBusinessLayer->unsetStarred($targetIds['albums'], $this->userId);
766
		$this->artistBusinessLayer->unsetStarred($targetIds['artists'], $this->userId);
767
		$this->podcastChannelBusinessLayer->unsetStarred($targetIds['podcast_channels'], $this->userId);
768
		$this->podcastEpisodeBusinessLayer->unsetStarred($targetIds['podcast_episodes'], $this->userId);
769
770
		return $this->subsonicResponse([]);
771
	}
772
773
	/**
774
	 * @SubsonicAPI
775
	 */
776
	private function getStarred() {
777
		$starred = $this->doGetStarred();
778
		return $this->searchResponse('starred', $starred, /*$useNewApi=*/false);
779
	}
780
781
	/**
782
	 * @SubsonicAPI
783
	 */
784
	private function getStarred2() {
785
		$starred = $this->doGetStarred();
786
		return $this->searchResponse('starred2', $starred, /*$useNewApi=*/true);
787
	}
788
789
	/**
790
	 * @SubsonicAPI
791
	 */
792
	private function getVideos() {
793
		// Feature not supported, return an empty list
794
		return $this->subsonicResponse([
795
			'videos' => [
796
				'video' => []
797
			]
798
		]);
799
	}
800
801
	/**
802
	 * @SubsonicAPI
803
	 */
804
	private function getPodcasts(?string $id, bool $includeEpisodes = true) {
805
		if ($id !== null) {
806
			$id = self::ripIdPrefix($id);
807
			$channel = $this->podcastService->getChannel($id, $this->userId, $includeEpisodes);
808
			if ($channel === null) {
809
				throw new SubsonicException('Requested channel not found', 70);
810
			}
811
			$channels = [$channel];
812
		} else {
813
			$channels = $this->podcastService->getAllChannels($this->userId, $includeEpisodes);
814
		}
815
816
		return $this->subsonicResponse([
817
			'podcasts' => [
818
				'channel' => Util::arrayMapMethod($channels, 'toSubsonicApi')
819
			]
820
		]);
821
	}
822
823
	/**
824
	 * @SubsonicAPI
825
	 */
826
	private function getNewestPodcasts(int $count=20) {
827
		$episodes = $this->podcastService->getLatestEpisodes($this->userId, $count);
828
829
		return $this->subsonicResponse([
830
			'newestPodcasts' => [
831
				'episode' => Util::arrayMapMethod($episodes, 'toSubsonicApi')
832
			]
833
		]);
834
	}
835
836
	/**
837
	 * @SubsonicAPI
838
	 */
839
	private function refreshPodcasts() {
840
		$this->podcastService->updateAllChannels($this->userId);
841
		return $this->subsonicResponse([]);
842
	}
843
844
	/**
845
	 * @SubsonicAPI
846
	 */
847
	private function createPodcastChannel(string $url) {
848
		$result = $this->podcastService->subscribe($url, $this->userId);
849
850
		switch ($result['status']) {
851
			case PodcastService::STATUS_OK:
852
				return $this->subsonicResponse([]);
853
			case PodcastService::STATUS_INVALID_URL:
854
				throw new SubsonicException("Invalid URL $url", 0);
855
			case PodcastService::STATUS_INVALID_RSS:
856
				throw new SubsonicException("The document at URL $url is not a valid podcast RSS feed", 0);
857
			case PodcastService::STATUS_ALREADY_EXISTS:
858
				throw new SubsonicException('User already has this podcast channel subscribed', 0);
859
			default:
860
				throw new SubsonicException("Unexpected status code {$result['status']}", 0);
861
		}
862
	}
863
864
	/**
865
	 * @SubsonicAPI
866
	 */
867
	private function deletePodcastChannel(string $id) {
868
		$id = self::ripIdPrefix($id);
869
		$status = $this->podcastService->unsubscribe($id, $this->userId);
870
871
		switch ($status) {
872
			case PodcastService::STATUS_OK:
873
				return $this->subsonicResponse([]);
874
			case PodcastService::STATUS_NOT_FOUND:
875
				throw new SubsonicException('Channel to be deleted not found', 70);
876
			default:
877
				throw new SubsonicException("Unexpected status code $status", 0);
878
		}
879
	}
880
881
	/**
882
	 * @SubsonicAPI
883
	 */
884
	private function getBookmarks() {
885
		$bookmarkNodes = [];
886
		$bookmarks = $this->bookmarkBusinessLayer->findAll($this->userId);
887
888
		foreach ($bookmarks as $bookmark) {
889
			$node = $bookmark->toSubsonicApi();
890
			$entryId = $bookmark->getEntryId();
891
			$type = $bookmark->getType();
892
893
			try {
894
				if ($type === Bookmark::TYPE_TRACK) {
895
					$track = $this->trackBusinessLayer->find($entryId, $this->userId);
896
					$track->setAlbum($this->albumBusinessLayer->find($track->getAlbumId(), $this->userId));
897
					$node['entry'] = $track->toSubsonicApi($this->l10n);
898
				} elseif ($type === Bookmark::TYPE_PODCAST_EPISODE) {
899
					$node['entry'] = $this->podcastEpisodeBusinessLayer->find($entryId, $this->userId)->toSubsonicApi();
900
				} else {
901
					$this->logger->log("Bookmark {$bookmark->getId()} had unexpected entry type $type", 'warn');
902
				}
903
				$bookmarkNodes[] = $node;
904
			} catch (BusinessLayerException $e) {
905
				$this->logger->log("Bookmarked entry with type $type and id $entryId not found", 'warn');
906
			}
907
		}
908
909
		return $this->subsonicResponse(['bookmarks' => ['bookmark' => $bookmarkNodes]]);
910
	}
911
912
	/**
913
	 * @SubsonicAPI
914
	 */
915
	private function createBookmark(string $id, int $position, ?string $comment) {
916
		list($type, $entityId) = self::parseBookamrkIdParam($id);
917
		$this->bookmarkBusinessLayer->addOrUpdate($this->userId, $type, $entityId, $position, $comment);
918
		return $this->subsonicResponse([]);
919
	}
920
921
	/**
922
	 * @SubsonicAPI
923
	 */
924
	private function deleteBookmark(string $id) {
925
		list($type, $entityId) = self::parseBookamrkIdParam($id);
926
927
		$bookmark = $this->bookmarkBusinessLayer->findByEntry($type, $entityId, $this->userId);
928
		$this->bookmarkBusinessLayer->delete($bookmark->getId(), $this->userId);
929
930
		return $this->subsonicResponse([]);
931
	}
932
933
	/**
934
	 * @SubsonicAPI
935
	 */
936
	private function getPlayQueue() {
937
		// TODO: not supported yet
938
		return $this->subsonicResponse(['playQueue' => []]);
939
	}
940
941
	/**
942
	 * @SubsonicAPI
943
	 */
944
	private function savePlayQueue() {
945
		// TODO: not supported yet
946
		return $this->subsonicResponse([]);
947
	}
948
949
	/* -------------------------------------------------------------------------
950
	 * Helper methods
951
	 *------------------------------------------------------------------------*/
952
953
	private static function ensureParamHasValue(string $paramName, /*mixed*/ $paramValue) {
954
		if ($paramValue === null || $paramValue === '') {
955
			throw new SubsonicException("Required parameter '$paramName' missing", 10);
956
		}
957
	}
958
959
	private static function parseBookamrkIdParam(string $id) : array {
960
		list($typeName, $entityId) = \explode('-', $id);
961
962
		if ($typeName === 'track') {
963
			$type = Bookmark::TYPE_TRACK;
964
		} elseif ($typeName === 'podcast_episode') {
965
			$type = Bookmark::TYPE_PODCAST_EPISODE;
966
		} else {
967
			throw new SubsonicException("Unsupported ID format $id", 0);
968
		}
969
970
		return [$type, (int)$entityId];
971
	}
972
973
	/**
974
	 * Parse parameters used in the `star` and `unstar` API methods
975
	 */
976
	private static function parseStarringParameters(array $ids, array $albumIds, array $artistIds) {
977
		// album IDs from newer clients
978
		$albumIds = \array_map('self::ripIdPrefix', $albumIds);
979
980
		// artist IDs from newer clients
981
		$artistIds = \array_map('self::ripIdPrefix', $artistIds);
982
983
		// Song IDs from newer clients and song/folder/album/artist IDs from older clients are all packed in $ids.
984
		// Also podcast IDs may come there; that is not documented part of the API but at least DSub does that.
985
986
		$trackIds = [];
987
		$channelIds = [];
988
		$episodeIds = [];
989
990
		foreach ($ids as $prefixedId) {
991
			$parts = \explode('-', $prefixedId);
992
			$type = $parts[0];
993
			$id = (int)$parts[1];
994
995
			if ($type == 'track') {
996
				$trackIds[] = $id;
997
			} elseif ($type == 'album') {
998
				$albumIds[] = $id;
999
			} elseif ($type == 'artist') {
1000
				$artistIds[] = $id;
1001
			} elseif ($type == 'podcast_channel') {
1002
				$channelIds[] = $id;
1003
			} elseif ($type == 'podcast_episode') {
1004
				$episodeIds[] = $id;
1005
			} elseif ($type == 'folder') {
1006
				throw new SubsonicException('Starring folders is not supported', 0);
1007
			} else {
1008
				throw new SubsonicException("Unexpected ID format: $prefixedId", 0);
1009
			}
1010
		}
1011
1012
		return [
1013
			'tracks' => $trackIds,
1014
			'albums' => $albumIds,
1015
			'artists' => $artistIds,
1016
			'podcast_channels' => $channelIds,
1017
			'podcast_episodes' => $episodeIds
1018
		];
1019
	}
1020
1021
	private function getFilesystemNode($id) {
1022
		$rootFolder = $this->userMusicFolder->getFolder($this->userId);
1023
		$nodes = $rootFolder->getById($id);
1024
1025
		if (\count($nodes) != 1) {
1026
			throw new SubsonicException('file not found', 70);
1027
		}
1028
1029
		return $nodes[0];
1030
	}
1031
1032
	private static function getIndexingChar(?string $name) {
1033
		// For unknown artists, use '?'
1034
		$char = '?';
1035
1036
		if (!empty($name)) {
1037
			$char = \mb_convert_case(\mb_substr($name, 0, 1), MB_CASE_UPPER);
1038
		}
1039
		// Bundle all numeric characters together
1040
		if (\is_numeric($char)) {
1041
			$char = '#';
1042
		}
1043
1044
		return $char;
1045
	}
1046
1047
	private function getSubfoldersAndTracks(Folder $folder) : array {
1048
		$nodes = $folder->getDirectoryListing();
1049
		$subFolders = \array_filter($nodes, function ($n) {
1050
			return ($n instanceof Folder) && $this->userMusicFolder->pathBelongsToMusicLibrary($n->getPath(), $this->userId);
1051
		});
1052
1053
		$tracks = $this->trackBusinessLayer->findAllByFolder($folder->getId(), $this->userId);
1054
1055
		return [$subFolders, $tracks];
1056
	}
1057
1058
	private function getIndexesForFolders() {
1059
		$rootFolder = $this->userMusicFolder->getFolder($this->userId);
1060
1061
		list($subFolders, $tracks) = $this->getSubfoldersAndTracks($rootFolder);
1062
1063
		$indexes = [];
1064
		foreach ($subFolders as $folder) {
1065
			$indexes[self::getIndexingChar($folder->getName())][] = [
1066
				'name' => $folder->getName(),
1067
				'id' => 'folder-' . $folder->getId()
1068
			];
1069
		}
1070
1071
		$folders = [];
1072
		foreach ($indexes as $indexChar => $bucketArtists) {
1073
			$folders[] = ['name' => $indexChar, 'artist' => $bucketArtists];
1074
		}
1075
1076
		return $this->subsonicResponse(['indexes' => [
1077
			'index' => $folders,
1078
			'child' => $this->tracksToApi($tracks)
1079
		]]);
1080
	}
1081
1082
	private function getMusicDirectoryForFolder($id) {
1083
		$folderId = self::ripIdPrefix($id); // get rid of 'folder-' prefix
1084
		$folder = $this->getFilesystemNode($folderId);
1085
1086
		if (!($folder instanceof Folder)) {
1087
			throw new SubsonicException("$id is not a valid folder", 70);
1088
		}
1089
1090
		list($subFolders, $tracks) = $this->getSubfoldersAndTracks($folder);
1091
1092
		$children = \array_merge(
1093
			\array_map([$this, 'folderToApi'], $subFolders),
1094
			$this->tracksToApi($tracks)
1095
		);
1096
1097
		$content = [
1098
			'directory' => [
1099
				'id' => $id,
1100
				'name' => $folder->getName(),
1101
				'child' => $children
1102
			]
1103
		];
1104
1105
		// Parent folder ID is included if and only if the parent folder is not the top level
1106
		$rootFolderId = $this->userMusicFolder->getFolder($this->userId)->getId();
1107
		$parentFolderId = $folder->getParent()->getId();
1108
		if ($rootFolderId != $parentFolderId) {
1109
			$content['parent'] = 'folder-' . $parentFolderId;
1110
		}
1111
1112
		return $this->subsonicResponse($content);
1113
	}
1114
1115
	private function getIndexesForArtists($rootElementName = 'indexes') {
1116
		$artists = $this->artistBusinessLayer->findAllHavingAlbums($this->userId, SortBy::Name);
1117
1118
		$indexes = [];
1119
		foreach ($artists as $artist) {
1120
			$indexes[self::getIndexingChar($artist->getName())][] = $this->artistToApi($artist);
1121
		}
1122
1123
		$result = [];
1124
		foreach ($indexes as $indexChar => $bucketArtists) {
1125
			$result[] = ['name' => $indexChar, 'artist' => $bucketArtists];
1126
		}
1127
1128
		return $this->subsonicResponse([$rootElementName => ['index' => $result]]);
1129
	}
1130
1131
	private function getMusicDirectoryForArtist($id) {
1132
		$artistId = self::ripIdPrefix($id); // get rid of 'artist-' prefix
1133
1134
		$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
1135
		$albums = $this->albumBusinessLayer->findAllByArtist($artistId, $this->userId);
1136
1137
		return $this->subsonicResponse([
1138
			'directory' => [
1139
				'id' => $id,
1140
				'name' => $artist->getNameString($this->l10n),
1141
				'child' => \array_map([$this, 'albumToOldApi'], $albums)
1142
			]
1143
		]);
1144
	}
1145
1146
	private function getMusicDirectoryForAlbum($id) {
1147
		$albumId = self::ripIdPrefix($id); // get rid of 'album-' prefix
1148
1149
		$album = $this->albumBusinessLayer->find($albumId, $this->userId);
1150
		$albumName = $album->getNameString($this->l10n);
1151
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $this->userId);
1152
1153
		return $this->subsonicResponse([
1154
			'directory' => [
1155
				'id' => $id,
1156
				'parent' => 'artist-' . $album->getAlbumArtistId(),
1157
				'name' => $albumName,
1158
				'child' => \array_map(function ($track) use ($album) {
1159
					$track->setAlbum($album);
1160
					return $track->toSubsonicApi($this->l10n);
1161
				}, $tracks)
1162
			]
1163
		]);
1164
	}
1165
1166
	private function getMusicDirectoryForPodcastChannel($id) {
1167
		$channelId = self::ripIdPrefix($id); // get rid of 'podcast_channel-' prefix
1168
		$channel = $this->podcastService->getChannel($channelId, $this->userId, /*$includeEpisodes=*/ true);
1169
1170
		if ($channel === null) {
1171
			throw new SubsonicException("Podcast channel $channelId not found", 0);
1172
		}
1173
1174
		return $this->subsonicResponse([
1175
			'directory' => [
1176
				'id' => $id,
1177
				'name' => $channel->getTitle(),
1178
				'child' => Util::arrayMapMethod($channel->getEpisodes(), 'toSubsonicApi')
1179
			]
1180
		]);
1181
	}
1182
1183
	/**
1184
	 * @param Folder $folder
1185
	 * @return array
1186
	 */
1187
	private function folderToApi($folder) {
1188
		return [
1189
			'id' => 'folder-' . $folder->getId(),
1190
			'title' => $folder->getName(),
1191
			'isDir' => true
1192
		];
1193
	}
1194
1195
	/**
1196
	 * @param Artist $artist
1197
	 * @return array
1198
	 */
1199
	private function artistToApi($artist) {
1200
		$id = $artist->getId();
1201
		$result = [
1202
			'name' => $artist->getNameString($this->l10n),
1203
			'id' => $id ? ('artist-' . $id) : '-1', // getArtistInfo may show artists without ID
1204
			'albumCount' => $id ? $this->albumBusinessLayer->countByArtist($id) : 0,
1205
			'starred' => Util::formatZuluDateTime($artist->getStarred())
1206
		];
1207
1208
		if (!empty($artist->getCoverFileId())) {
1209
			$result['coverArt'] = $result['id'];
1210
		}
1211
1212
		return $result;
1213
	}
1214
1215
	/**
1216
	 * The "old API" format is used e.g. in getMusicDirectory and getAlbumList
1217
	 */
1218
	private function albumToOldApi(Album $album) : array {
1219
		$result = $this->albumCommonApiFields($album);
1220
1221
		$result['parent'] = 'artist-' . $album->getAlbumArtistId();
1222
		$result['title'] = $album->getNameString($this->l10n);
1223
		$result['isDir'] = true;
1224
1225
		return $result;
1226
	}
1227
1228
	/**
1229
	 * The "new API" format is used e.g. in getAlbum and getAlbumList2
1230
	 */
1231
	private function albumToNewApi(Album $album) : array {
1232
		$result = $this->albumCommonApiFields($album);
1233
1234
		$result['artistId'] = 'artist-' . $album->getAlbumArtistId();
1235
		$result['name'] = $album->getNameString($this->l10n);
1236
		$result['songCount'] = $this->trackBusinessLayer->countByAlbum($album->getId());
1237
		$result['duration'] = $this->trackBusinessLayer->totalDurationOfAlbum($album->getId());
1238
1239
		return $result;
1240
	}
1241
1242
	private function albumCommonApiFields(Album $album) : array {
1243
		$genreString = \implode(', ', \array_map(function (Genre $genre) {
1244
			return $genre->getNameString($this->l10n);
1245
		}, $album->getGenres() ?? []));
1246
1247
		return [
1248
			'id' => 'album-' . $album->getId(),
1249
			'artist' => $album->getAlbumArtistNameString($this->l10n),
1250
			'created' => Util::formatZuluDateTime($album->getCreated()),
1251
			'coverArt' => empty($album->getCoverFileId()) ? null : 'album-' . $album->getId(),
1252
			'starred' => Util::formatZuluDateTime($album->getStarred()),
1253
			'year' => $album->yearToAPI(),
1254
			'genre' => $genreString ?: null
1255
		];
1256
	}
1257
1258
	/**
1259
	 * @param Track[] $tracks
1260
	 * @return array
1261
	 */
1262
	private function tracksToApi(array $tracks) : array {
1263
		$this->albumBusinessLayer->injectAlbumsToTracks($tracks, $this->userId);
1264
		return Util::arrayMapMethod($tracks, 'toSubsonicApi', [$this->l10n]);
1265
	}
1266
1267
	/**
1268
	 * Common logic for getAlbumList and getAlbumList2
1269
	 * @return Album[]
1270
	 */
1271
	private function albumsForGetAlbumList(
1272
			string $type, ?string $genre, ?int $fromYear, ?int $toYear, int $size, int $offset) : array {
1273
		$size = \min($size, 500); // the API spec limits the maximum amount to 500
1274
1275
		$albums = [];
1276
1277
		switch ($type) {
1278
			case 'random':
1279
				$allAlbums = $this->albumBusinessLayer->findAll($this->userId);
1280
				$indices = $this->random->getIndices(\count($allAlbums), $offset, $size, $this->userId, 'subsonic_albums');
1281
				$albums = Util::arrayMultiGet($allAlbums, $indices);
1282
				break;
1283
			case 'starred':
1284
				$albums = $this->albumBusinessLayer->findAllStarred($this->userId, $size, $offset);
1285
				break;
1286
			case 'alphabeticalByName':
1287
				$albums = $this->albumBusinessLayer->findAll($this->userId, SortBy::Name, $size, $offset);
1288
				break;
1289
			case 'alphabeticalByArtist':
1290
				$albums = $this->albumBusinessLayer->findAll($this->userId, SortBy::Parent, $size, $offset);
1291
				break;
1292
			case 'byGenre':
1293
				self::ensureParamHasValue('genre', $genre);
1294
				$albums = $this->findAlbumsByGenre($genre, $size, $offset);
1295
				break;
1296
			case 'byYear':
1297
				self::ensureParamHasValue('fromYear', $fromYear);
1298
				self::ensureParamHasValue('toYear', $toYear);
1299
				$albums = $this->albumBusinessLayer->findAllByYearRange($fromYear, $toYear, $this->userId, $size, $offset);
1300
				break;
1301
			case 'newest':
1302
				$albums = $this->albumBusinessLayer->findAll($this->userId, SortBy::Newest, $size, $offset);
1303
				break;
1304
			case 'highest':
1305
			case 'frequent':
1306
			case 'recent':
1307
			default:
1308
				$this->logger->log("Album list type '$type' is not supported", 'debug');
1309
				break;
1310
		}
1311
1312
		return $albums;
1313
	}
1314
1315
	/**
1316
	 * Common logic for getArtistInfo and getArtistInfo2
1317
	 */
1318
	private function doGetArtistInfo(string $rootName, string $id, bool $includeNotPresent) {
1319
		$content = [];
1320
1321
		// This function may be called with a folder ID instead of an artist ID in case
1322
		// the library is being browsed by folders. In that case, return an empty response.
1323
		if (Util::startsWith($id, 'artist')) {
1324
			$artistId = self::ripIdPrefix($id); // get rid of 'artist-' prefix
1325
1326
			$info = $this->lastfmService->getArtistInfo($artistId, $this->userId);
1327
1328
			if (isset($info['artist'])) {
1329
				$content = [
1330
					'biography' => $info['artist']['bio']['summary'],
1331
					'lastFmUrl' => $info['artist']['url'],
1332
					'musicBrainzId' => $info['artist']['mbid'] ?? null
1333
				];
1334
1335
				$similarArtists = $this->lastfmService->getSimilarArtists($artistId, $this->userId, $includeNotPresent);
1336
				$content['similarArtist'] = \array_map([$this, 'artistToApi'], $similarArtists);
1337
			}
1338
1339
			$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
1340
			if ($artist->getCoverFileId() !== null) {
1341
				$par = $this->request->getParams();
1342
				$url = $this->urlGenerator->linkToRouteAbsolute('music.subsonic.handleRequest', ['method' => 'getCoverArt'])
1343
						. "?u={$par['u']}&p={$par['p']}&v={$par['v']}&c={$par['c']}&id=$id";
1344
				$content['largeImageUrl'] = [$url];
1345
			}
1346
		}
1347
1348
		// This method is unusual in how it uses non-attribute elements in the response. On the other hand,
1349
		// all the details of the <similarArtist> elements are rendered as attributes. List those separately.
1350
		$attributeKeys = ['name', 'id', 'albumCount', 'coverArt', 'starred'];
1351
1352
		return $this->subsonicResponse([$rootName => $content], $attributeKeys);
1353
	}
1354
1355
	/**
1356
	 * Common logic for getAlbumInfo and getAlbumInfo2
1357
	 */
1358
	private function doGetAlbumInfo(string $rootName, string $id) {
1359
		$content = [];
1360
1361
		// This function may be called with a folder ID instead of an album ID in case
1362
		// the library is being browsed by folders. In that case, return an empty response.
1363
		if (Util::startsWith($id, 'album')) {
1364
			$albumId = self::ripIdPrefix($id); // get rid of 'album-' prefix
1365
1366
			$info = $this->lastfmService->getAlbumInfo($albumId, $this->userId);
1367
1368
			if (isset($info['album'])) {
1369
				$content = [
1370
					'notes' => $info['album']['wiki']['summary'] ?? null,
1371
					'lastFmUrl' => $info['album']['url'],
1372
					'musicBrainzId' => $info['album']['mbid'] ?? null
1373
				];
1374
1375
				foreach ($info['album']['image'] ?? [] as $imageInfo) {
1376
					if (!empty($imageInfo['size'])) {
1377
						$content[$imageInfo['size'] . 'ImageUrl'] = $imageInfo['#text'];
1378
					}
1379
				}
1380
			}
1381
		}
1382
1383
		// This method is unusual in how it uses non-attribute elements in the response.
1384
		return $this->subsonicResponse([$rootName => $content], []);
1385
	}
1386
1387
	/**
1388
	 * Common logic for getSimilarSongs and getSimilarSongs2
1389
	 */
1390
	private function doGetSimilarSongs(string $rootName, string $id, int $count) {
1391
		if (Util::startsWith($id, 'artist')) {
1392
			$artistId = self::ripIdPrefix($id);
1393
		} elseif (Util::startsWith($id, 'album')) {
1394
			$albumId = self::ripIdPrefix($id);
1395
			$artistId = $this->albumBusinessLayer->find($albumId, $this->userId)->getAlbumArtistId();
1396
		} elseif (Util::startsWith($id, 'track')) {
1397
			$trackId = self::ripIdPrefix($id);
1398
			$artistId = $this->trackBusinessLayer->find($trackId, $this->userId)->getArtistId();
1399
		} else {
1400
			throw new SubsonicException("Id $id has a type not supported on getSimilarSongs", 0);
1401
		}
1402
1403
		$artists = $this->lastfmService->getSimilarArtists($artistId, $this->userId);
1404
		$artists[] = $this->artistBusinessLayer->find($artistId, $this->userId);
1405
1406
		// Get all songs by the found artists
1407
		$songs = [];
1408
		foreach ($artists as $artist) {
1409
			$songs = \array_merge($songs, $this->trackBusinessLayer->findAllByArtist($artist->getId(), $this->userId));
1410
		}
1411
1412
		// Randomly select the desired number of songs
1413
		$songs = $this->random->pickItems($songs, $count);
1414
1415
		return $this->subsonicResponse([$rootName => [
1416
			'song' => $this->tracksToApi($songs)
1417
		]]);
1418
	}
1419
1420
	/**
1421
	 * Common logic for search2 and search3
1422
	 * @return array with keys 'artists', 'albums', and 'tracks'
1423
	 */
1424
	private function doSearch(string $query, int $artistCount, int $artistOffset,
1425
			int $albumCount, int $albumOffset, int $songCount, int $songOffset) {
1426
1427
		if (empty($query)) {
1428
			throw new SubsonicException("The 'query' argument is mandatory", 10);
1429
		}
1430
1431
		return [
1432
			'artists' => $this->artistBusinessLayer->findAllByName($query, $this->userId, true, $artistCount, $artistOffset),
1433
			'albums' => $this->albumBusinessLayer->findAllByName($query, $this->userId, true, $albumCount, $albumOffset),
1434
			'tracks' => $this->trackBusinessLayer->findAllByName($query, $this->userId, true, $songCount, $songOffset)
1435
		];
1436
	}
1437
1438
	/**
1439
	 * Common logic for getStarred and getStarred2
1440
	 * @return array
1441
	 */
1442
	private function doGetStarred() {
1443
		return [
1444
			'artists' => $this->artistBusinessLayer->findAllStarred($this->userId),
1445
			'albums' => $this->albumBusinessLayer->findAllStarred($this->userId),
1446
			'tracks' => $this->trackBusinessLayer->findAllStarred($this->userId)
1447
		];
1448
	}
1449
1450
	/**
1451
	 * Common response building logic for search2, search3, getStarred, and getStarred2
1452
	 * @param string $title Name of the main node in the response message
1453
	 * @param array $results Search results with keys 'artists', 'albums', and 'tracks'
1454
	 * @param boolean $useNewApi Set to true for search3 and getStarred2. There is a difference
1455
	 *                           in the formatting of the album nodes.
1456
	 * @return \OCP\AppFramework\Http\Response
1457
	 */
1458
	private function searchResponse($title, $results, $useNewApi) {
1459
		$albumMapFunc = $useNewApi ? 'albumToNewApi' : 'albumToOldApi';
1460
1461
		return $this->subsonicResponse([$title => [
1462
			'artist' => \array_map([$this, 'artistToApi'], $results['artists']),
1463
			'album' => \array_map([$this, $albumMapFunc], $results['albums']),
1464
			'song' => $this->tracksToApi($results['tracks'])
1465
		]]);
1466
	}
1467
1468
	/**
1469
	 * Find tracks by genre name
1470
	 * @param string $genreName
1471
	 * @param int|null $limit
1472
	 * @param int|null $offset
1473
	 * @return Track[]
1474
	 */
1475
	private function findTracksByGenre($genreName, $limit=null, $offset=null) {
1476
		$genre = $this->findGenreByName($genreName);
1477
1478
		if ($genre) {
1479
			return $this->trackBusinessLayer->findAllByGenre($genre->getId(), $this->userId, $limit, $offset);
1480
		} else {
1481
			return [];
1482
		}
1483
	}
1484
1485
	/**
1486
	 * Find albums by genre name
1487
	 * @param string $genreName
1488
	 * @param int|null $limit
1489
	 * @param int|null $offset
1490
	 * @return Album[]
1491
	 */
1492
	private function findAlbumsByGenre($genreName, $limit=null, $offset=null) {
1493
		$genre = $this->findGenreByName($genreName);
1494
1495
		if ($genre) {
1496
			return $this->albumBusinessLayer->findAllByGenre($genre->getId(), $this->userId, $limit, $offset);
1497
		} else {
1498
			return [];
1499
		}
1500
	}
1501
1502
	private function findGenreByName($name) {
1503
		$genreArr = $this->genreBusinessLayer->findAllByName($name, $this->userId);
1504
		if (\count($genreArr) == 0 && $name == Genre::unknownNameString($this->l10n)) {
1505
			$genreArr = $this->genreBusinessLayer->findAllByName('', $this->userId);
1506
		}
1507
		return \count($genreArr) ? $genreArr[0] : null;
1508
	}
1509
1510
	/**
1511
	 * Given a prefixed ID like 'artist-123' or 'track-45', return just the numeric part.
1512
	 */
1513
	private static function ripIdPrefix(string $id) : int {
1514
		return (int)(\explode('-', $id)[1]);
1515
	}
1516
1517
	private function subsonicResponse($content, $useAttributes=true, $status = 'ok') {
1518
		$content['status'] = $status;
1519
		$content['version'] = self::API_VERSION;
1520
		$responseData = ['subsonic-response' => Util::arrayRejectRecursive($content, 'is_null')];
1521
1522
		if ($this->format == 'json') {
1523
			$response = new JSONResponse($responseData);
1524
		} elseif ($this->format == 'jsonp') {
1525
			$responseData = \json_encode($responseData);
1526
			$response = new DataDisplayResponse("{$this->callback}($responseData);");
1527
			$response->addHeader('Content-Type', 'text/javascript; charset=UTF-8');
1528
		} else {
1529
			if (\is_array($useAttributes)) {
1530
				$useAttributes = \array_merge($useAttributes, ['status', 'version']);
1531
			}
1532
			$response = new XmlResponse($responseData, $useAttributes);
1533
		}
1534
1535
		return $response;
1536
	}
1537
1538
	public function subsonicErrorResponse($errorCode, $errorMessage) {
1539
		return $this->subsonicResponse([
1540
				'error' => [
1541
					'code' => $errorCode,
1542
					'message' => $errorMessage
1543
				]
1544
			], true, 'failed');
1545
	}
1546
}
1547