Passed
Pull Request — master (#875)
by Pauli
04:37 queued 02:12
created

SubsonicController::getIndexesForFolders()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

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