Passed
Push — feature/786_podcasts ( 289482...8c58bb )
by Pauli
02:31
created

SubsonicController   F

Complexity

Total Complexity 197

Size/Duplication

Total Lines 1576
Duplicated Lines 0 %

Importance

Changes 6
Bugs 0 Features 0
Metric Value
eloc 734
c 6
b 0
f 0
dl 0
loc 1576
rs 1.866
wmc 197

85 Methods

Rating   Name   Duplication   Size   Complexity  
A setResponseFormat() 0 3 1
A ping() 0 2 1
A getIndexes() 0 7 2
A getMusicFolders() 0 6 1
A __construct() 0 44 1
A handleRequest() 0 18 4
A getLicense() 0 4 1
A setAuthenticatedUser() 0 2 1
A getSimilarSongs2() 0 2 1
A getArtists() 0 2 1
A getMusicDirectory() 0 13 5
A getArtistInfo2() 0 2 1
A getAlbumList2() 0 11 1
A getArtist() 0 11 1
A getSimilarSongs() 0 2 1
A getAlbumList() 0 4 1
A getArtistInfo() 0 2 1
A refreshPodcasts() 0 3 1
A getIndexesForArtists() 0 14 3
A findTracksByGenre() 0 7 2
A getFilesystemNode() 0 9 2
A albumCommonApiFields() 0 26 5
A getSongsByGenre() 0 9 1
A getUser() 0 25 2
B getCoverArt() 0 26 7
A createPodcastChannel() 0 15 5
A getRandomSongs() 0 29 6
A folderToApi() 0 5 1
A artistToApi() 0 17 5
A getMusicDirectoryForPodcastChannel() 0 13 2
A createPlaylist() 0 20 3
A getGenres() 0 13 1
A savePlayQueue() 0 3 1
B getStarringParameters() 0 45 8
A getBookamrkIdParam() 0 13 3
A getNewestPodcasts() 0 8 1
A getIndexesForFolders() 0 6 1
A subsonicResponse() 0 19 4
B trackToApi() 0 49 10
A getPodcasts() 0 18 3
A getTopSongs() 0 3 1
A parseRepeatedKeyValues() 0 14 3
A getStarred2() 0 3 1
A getRequiredParam() 0 8 2
A ripIdPrefix() 0 2 1
A getPlayQueue() 0 3 1
A injectAlbumsToTracks() 0 18 3
A getSong() 0 6 1
A updatePlaylist() 0 25 5
A unstar() 0 10 1
A getMusicDirectoryForArtist() 0 11 1
A search3() 0 3 1
A search2() 0 3 1
A getInternetRadioStations() 0 11 1
A download() 0 25 5
A star() 0 10 1
A getUsers() 0 2 1
A getLyrics() 0 25 3
B albumsForGetAlbumList() 0 44 11
A deletePodcastChannel() 0 12 3
A createBookmark() 0 10 1
A getAlbum() 0 13 1
A playlistToApi() 0 11 2
A doGetArtistInfo() 0 39 4
A getBookmarks() 0 24 5
A getAvatar() 0 12 3
A doGetSimilarSongs() 0 30 5
A albumToNewApi() 0 9 1
A getRepeatedParam() 0 16 2
A findGenreByName() 0 6 4
A deleteBookmark() 0 7 1
A doGetStarred() 0 5 1
A getStarred() 0 3 1
A doSearch() 0 17 2
A stream() 0 3 1
A albumToOldApi() 0 8 1
A getPlaylist() 0 9 1
A deletePlaylist() 0 4 1
A findAlbumsByGenre() 0 7 2
A getVideos() 0 5 1
A getMusicDirectoryForAlbum() 0 16 1
A getPlaylists() 0 5 1
A subsonicErrorResponse() 0 7 1
A searchResponse() 0 7 2
A getMusicDirectoryForFolder() 0 40 3

How to fix   Complexity   

Complex Class

Complex classes like SubsonicController often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use SubsonicController, and based on these observations, apply Extract Interface, too.

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