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