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

SubsonicController   F

Complexity

Total Complexity 184

Size/Duplication

Total Lines 1490
Duplicated Lines 0 %

Importance

Changes 6
Bugs 0 Features 0
Metric Value
eloc 687
dl 0
loc 1490
rs 1.913
c 6
b 0
f 0
wmc 184

80 Methods

Rating   Name   Duplication   Size   Complexity  
A getSimilarSongs2() 0 2 1
A refreshPodcasts() 0 3 1
A getIndexesForArtists() 0 14 3
A bookmarkToApi() 0 7 2
A getArtists() 0 2 1
A getMusicDirectory() 0 9 3
A getArtistInfo2() 0 2 1
A findTracksByGenre() 0 7 2
A getFilesystemNode() 0 9 2
A albumCommonApiFields() 0 26 5
A getAlbumList2() 0 11 1
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 setResponseFormat() 0 3 1
A artistToApi() 0 17 5
A ping() 0 2 1
A getArtist() 0 11 1
A createPlaylist() 0 20 3
A getGenres() 0 13 1
A getIndexes() 0 7 2
B getStarringParameters() 0 36 6
A getIndexesForFolders() 0 6 1
A subsonicResponse() 0 19 4
A getMusicFolders() 0 6 1
B trackToApi() 0 49 10
A getPodcasts() 0 18 3
A parseRepeatedKeyValues() 0 14 3
A getStarred2() 0 3 1
A getSimilarSongs() 0 2 1
A getRequiredParam() 0 8 2
A ripIdPrefix() 0 2 1
A injectAlbumsToTracks() 0 18 3
A getAlbumList() 0 4 1
A getSong() 0 6 1
A updatePlaylist() 0 25 5
A unstar() 0 8 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 8 1
A getUsers() 0 2 1
A getLyrics() 0 25 3
B albumsForGetAlbumList() 0 44 11
A deletePodcastChannel() 0 12 3
A createBookmark() 0 9 1
A getAlbum() 0 13 1
A playlistToApi() 0 11 2
A doGetArtistInfo() 0 39 4
A getBookmarks() 0 18 3
A __construct() 0 40 1
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 9 1
A handleRequest() 0 18 4
A doGetStarred() 0 5 1
A getStarred() 0 3 1
A doSearch() 0 17 2
A getArtistInfo() 0 2 1
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 getLicense() 0 4 1
A searchResponse() 0 7 2
A getMusicDirectoryForFolder() 0 40 3
A setAuthenticatedUser() 0 2 1

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