Passed
Push — feature/786_podcasts ( da2795...426725 )
by Pauli
02:03
created

SubsonicController::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 44
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

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

How to fix   Many Parameters   

Many Parameters

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

There are several approaches to avoid long parameter lists:

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