Passed
Push — master ( efd3f2...e4fc68 )
by Pauli
02:06
created

SubsonicController   F

Complexity

Total Complexity 217

Size/Duplication

Total Lines 1575
Duplicated Lines 0 %

Importance

Changes 20
Bugs 0 Features 0
Metric Value
wmc 217
eloc 712
c 20
b 0
f 0
dl 0
loc 1575
rs 1.888

95 Methods

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