Passed
Push — feature/786_podcasts ( 7b8be7...af6910 )
by Pauli
02:22
created

SubsonicController::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 40
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

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