Passed
Pull Request — master (#1079)
by
unknown
04:01
created

SubsonicController::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 42
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 20
c 0
b 0
f 0
nc 1
nop 21
dl 0
loc 42
rs 9.6

How to fix   Many Parameters   

Many Parameters

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

There are several approaches to avoid long parameter lists:

1
<?php declare(strict_types=1);
2
3
/**
4
 * ownCloud - Music app
5
 *
6
 * This file is licensed under the Affero General Public License version 3 or
7
 * later. See the COPYING file.
8
 *
9
 * @author Pauli Järvinen <[email protected]>
10
 * @copyright Pauli Järvinen 2019 - 2022
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\PlaylistBusinessLayer;
36
use OCA\Music\BusinessLayer\PodcastChannelBusinessLayer;
37
use OCA\Music\BusinessLayer\PodcastEpisodeBusinessLayer;
38
use OCA\Music\BusinessLayer\RadioStationBusinessLayer;
39
use OCA\Music\BusinessLayer\TrackBusinessLayer;
40
41
use OCA\Music\Db\Album;
42
use OCA\Music\Db\Artist;
43
use OCA\Music\Db\Bookmark;
44
use OCA\Music\Db\Genre;
45
use OCA\Music\Db\MatchMode;
46
use OCA\Music\Db\PodcastEpisode;
47
use OCA\Music\Db\SortBy;
48
use OCA\Music\Db\Track;
49
50
use OCA\Music\Http\FileResponse;
51
use OCA\Music\Http\FileStreamResponse;
52
use OCA\Music\Http\XmlResponse;
53
54
use OCA\Music\Middleware\SubsonicException;
55
56
use OCA\Music\Utility\CoverHelper;
57
use OCA\Music\Utility\DetailsHelper;
58
use OCA\Music\Utility\LastfmService;
59
use OCA\Music\Utility\LibrarySettings;
60
use OCA\Music\Utility\PodcastService;
61
use OCA\Music\Utility\Random;
62
use OCA\Music\Utility\Util;
63
use OCA\Music\Utility\PlaceholderImage;
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 $urlGenerator;
78
	private $userManager;
79
	private $librarySettings;
80
	private $l10n;
81
	private $coverHelper;
82
	private $detailsHelper;
83
	private $lastfmService;
84
	private $podcastService;
85
	private $random;
86
	private $logger;
87
	private $userId;
88
	private $format;
89
	private $callback;
90
91
	public function __construct(string $appname,
92
								IRequest $request,
93
								$l10n,
94
								IURLGenerator $urlGenerator,
95
								IUserManager $userManager,
96
								AlbumBusinessLayer $albumBusinessLayer,
97
								ArtistBusinessLayer $artistBusinessLayer,
98
								BookmarkBusinessLayer $bookmarkBusinessLayer,
99
								GenreBusinessLayer $genreBusinessLayer,
100
								PlaylistBusinessLayer $playlistBusinessLayer,
101
								PodcastChannelBusinessLayer $podcastChannelBusinessLayer,
102
								PodcastEpisodeBusinessLayer $podcastEpisodeBusinessLayer,
103
								RadioStationBusinessLayer $radioStationBusinessLayer,
104
								TrackBusinessLayer $trackBusinessLayer,
105
								LibrarySettings $librarySettings,
106
								CoverHelper $coverHelper,
107
								DetailsHelper $detailsHelper,
108
								LastfmService $lastfmService,
109
								PodcastService $podcastService,
110
								Random $random,
111
								Logger $logger) {
112
		parent::__construct($appname, $request);
113
114
		$this->albumBusinessLayer = $albumBusinessLayer;
115
		$this->artistBusinessLayer = $artistBusinessLayer;
116
		$this->bookmarkBusinessLayer = $bookmarkBusinessLayer;
117
		$this->genreBusinessLayer = $genreBusinessLayer;
118
		$this->playlistBusinessLayer = $playlistBusinessLayer;
119
		$this->podcastChannelBusinessLayer = $podcastChannelBusinessLayer;
120
		$this->podcastEpisodeBusinessLayer = $podcastEpisodeBusinessLayer;
121
		$this->radioStationBusinessLayer = $radioStationBusinessLayer;
122
		$this->trackBusinessLayer = $trackBusinessLayer;
123
		$this->urlGenerator = $urlGenerator;
124
		$this->userManager = $userManager;
125
		$this->l10n = $l10n;
126
		$this->librarySettings = $librarySettings;
127
		$this->coverHelper = $coverHelper;
128
		$this->detailsHelper = $detailsHelper;
129
		$this->lastfmService = $lastfmService;
130
		$this->podcastService = $podcastService;
131
		$this->random = $random;
132
		$this->logger = $logger;
133
	}
134
135
	/**
136
	 * Called by the middleware to set the reponse format to be used
137
	 * @param string $format Response format: xml/json/jsonp
138
	 * @param string|null $callback Function name to use if the @a $format is 'jsonp'
139
	 */
140
	public function setResponseFormat(string $format, string $callback = null) {
141
		$this->format = $format;
142
		$this->callback = $callback;
143
	}
144
145
	/**
146
	 * Called by the middleware once the user credentials have been checked
147
	 * @param string $userId
148
	 */
149
	public function setAuthenticatedUser(string $userId) {
150
		$this->userId = $userId;
151
	}
152
153
	/**
154
	 * @NoAdminRequired
155
	 * @PublicPage
156
	 * @NoCSRFRequired
157
	 * @NoSameSiteCookieRequired
158
	 */
159
	public function handleRequest($method) {
160
		$this->logger->log("Subsonic request $method", 'debug');
161
162
		// Allow calling all methods with or without the postfix ".view"
163
		if (Util::endsWith($method, ".view")) {
164
			$method = \substr($method, 0, -\strlen(".view"));
165
		}
166
167
		// Allow calling any functions annotated to be part of the API
168
		if (\method_exists($this, $method)) {
169
			$annotationReader = new MethodAnnotationReader($this, $method);
170
			if ($annotationReader->hasAnnotation('SubsonicAPI')) {
171
				$parameterExtractor = new RequestParameterExtractor($this->request);
172
				try {
173
					$parameterValues = $parameterExtractor->getParametersForMethod($this, $method);
174
				} catch (RequestParameterExtractorException $ex) {
175
					return $this->subsonicErrorResponse(10, $ex->getMessage());
176
				}
177
				return \call_user_func_array([$this, $method], $parameterValues);
178
			}
179
		}
180
181
		$this->logger->log("Request $method not supported", 'warn');
182
		return $this->subsonicErrorResponse(70, "Requested action $method is not supported");
183
	}
184
185
	/* -------------------------------------------------------------------------
186
	 * REST API methods
187
	 *------------------------------------------------------------------------*/
188
189
	/**
190
	 * @SubsonicAPI
191
	 */
192
	protected function ping() {
193
		return $this->subsonicResponse([]);
194
	}
195
196
	/**
197
	 * @SubsonicAPI
198
	 */
199
	protected function getLicense() {
200
		return $this->subsonicResponse([
201
			'license' => [
202
				'valid' => 'true'
203
			]
204
		]);
205
	}
206
207
	/**
208
	 * @SubsonicAPI
209
	 */
210
	protected function getMusicFolders() {
211
		// Only single root folder is supported
212
		return $this->subsonicResponse([
213
			'musicFolders' => ['musicFolder' => [
214
				['id' => 'artists', 'name' => $this->l10n->t('Artists')],
215
				['id' => 'folders', 'name' => $this->l10n->t('Folders')]
216
			]]
217
		]);
218
	}
219
220
	/**
221
	 * @SubsonicAPI
222
	 */
223
	protected function getIndexes(?string $musicFolderId) {
224
		if ($musicFolderId === 'folders') {
225
			return $this->getIndexesForFolders();
226
		} else {
227
			return $this->getIndexesForArtists();
228
		}
229
	}
230
231
	/**
232
	 * @SubsonicAPI
233
	 */
234
	protected function getMusicDirectory(string $id) {
235
		if (Util::startsWith($id, 'folder-')) {
236
			return $this->getMusicDirectoryForFolder($id);
237
		} elseif (Util::startsWith($id, 'artist-')) {
238
			return $this->getMusicDirectoryForArtist($id);
239
		} elseif (Util::startsWith($id, 'album-')) {
240
			return $this->getMusicDirectoryForAlbum($id);
241
		} elseif (Util::startsWith($id, 'podcast_channel-')) {
242
			return $this->getMusicDirectoryForPodcastChannel($id);
243
		} else {
244
			throw new SubsonicException("Unsupported id format $id");
245
		}
246
	}
247
248
	/**
249
	 * @SubsonicAPI
250
	 */
251
	protected function getAlbumList(
252
			string $type, ?string $genre, ?int $fromYear, ?int $toYear, int $size=10, int $offset=0) {
253
		$albums = $this->albumsForGetAlbumList($type, $genre, $fromYear, $toYear, $size, $offset);
254
		return $this->subsonicResponse(['albumList' =>
255
				['album' => \array_map([$this, 'albumToOldApi'], $albums)]
256
		]);
257
	}
258
259
	/**
260
	 * @SubsonicAPI
261
	 */
262
	protected function getAlbumList2(
263
			string $type, ?string $genre, ?int $fromYear, ?int $toYear, int $size=10, int $offset=0) {
264
		/*
265
		 * According to the API specification, the difference between this and getAlbumList
266
		 * should be that this function would organize albums according the metadata while
267
		 * getAlbumList would organize them by folders. However, we organize by metadata
268
		 * also in getAlbumList, because that's more natural for the Music app and many/most
269
		 * clients do not support getAlbumList2.
270
		 */
271
		$albums = $this->albumsForGetAlbumList($type, $genre, $fromYear, $toYear, $size, $offset);
272
		return $this->subsonicResponse(['albumList2' =>
273
				['album' => \array_map([$this, 'albumToNewApi'], $albums)]
274
		]);
275
	}
276
277
	/**
278
	 * @SubsonicAPI
279
	 */
280
	protected function getArtists() {
281
		return $this->getIndexesForArtists('artists');
282
	}
283
284
	/**
285
	 * @SubsonicAPI
286
	 */
287
	protected function getArtist(string $id) {
288
		$artistId = self::ripIdPrefix($id); // get rid of 'artist-' prefix
289
290
		$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
291
		$albums = $this->albumBusinessLayer->findAllByArtist($artistId, $this->userId);
292
293
		$artistNode = $this->artistToApi($artist);
294
		$artistNode['album'] = \array_map([$this, 'albumToNewApi'], $albums);
295
296
		return $this->subsonicResponse(['artist' => $artistNode]);
297
	}
298
299
	/**
300
	 * @SubsonicAPI
301
	 */
302
	protected function getArtistInfo(string $id, bool $includeNotPresent=false) {
303
		return $this->doGetArtistInfo('artistInfo', $id, $includeNotPresent);
304
	}
305
306
	/**
307
	 * @SubsonicAPI
308
	 */
309
	protected function getArtistInfo2(string $id, bool $includeNotPresent=false) {
310
		return $this->doGetArtistInfo('artistInfo2', $id, $includeNotPresent);
311
	}
312
313
	/**
314
	 * @SubsonicAPI
315
	 */
316
	protected function getAlbumInfo(string $id) {
317
		return $this->doGetAlbumInfo('albumInfo', $id);
318
	}
319
320
	/**
321
	 * @SubsonicAPI
322
	 */
323
	protected function getAlbumInfo2(string $id) {
324
		return $this->doGetAlbumInfo('albumInfo2', $id);
325
	}
326
327
	/**
328
	 * @SubsonicAPI
329
	 */
330
	protected function getSimilarSongs(string $id, int $count=50) {
331
		return $this->doGetSimilarSongs('similarSongs', $id, $count);
332
	}
333
334
	/**
335
	 * @SubsonicAPI
336
	 */
337
	protected function getSimilarSongs2(string $id, int $count=50) {
338
		return $this->doGetSimilarSongs('similarSongs2', $id, $count);
339
	}
340
341
	/**
342
	 * @SubsonicAPI
343
	 */
344
	protected function getTopSongs(string $artist, int $count=50) {
345
		$tracks = $this->lastfmService->getTopTracks($artist, $this->userId, $count);
346
		return $this->subsonicResponse(['topSongs' =>
347
			['song' => $this->tracksToApi($tracks)]
348
		]);
349
	}
350
351
	/**
352
	 * @SubsonicAPI
353
	 */
354
	protected function getAlbum(string $id) {
355
		$albumId = self::ripIdPrefix($id); // get rid of 'album-' prefix
356
357
		$album = $this->albumBusinessLayer->find($albumId, $this->userId);
358
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $this->userId);
359
360
		$albumNode = $this->albumToNewApi($album);
361
		$albumNode['song'] = \array_map(function ($track) use ($album) {
362
			$track->setAlbum($album);
363
			return $track->toSubsonicApi($this->l10n);
364
		}, $tracks);
365
		return $this->subsonicResponse(['album' => $albumNode]);
366
	}
367
368
	/**
369
	 * @SubsonicAPI
370
	 */
371
	protected function getSong(string $id) {
372
		$trackId = self::ripIdPrefix($id); // get rid of 'track-' prefix
373
		$track = $this->trackBusinessLayer->find($trackId, $this->userId);
374
		$track->setAlbum($this->albumBusinessLayer->find($track->getAlbumId(), $this->userId));
375
376
		return $this->subsonicResponse(['song' => $track->toSubsonicApi($this->l10n)]);
377
	}
378
379
	/**
380
	 * @SubsonicAPI
381
	 */
382
	protected function getRandomSongs(?string $genre, ?string $fromYear, ?string $toYear, int $size=10) {
383
		$size = \min($size, 500); // the API spec limits the maximum amount to 500
384
385
		if ($genre !== null) {
386
			$trackPool = $this->findTracksByGenre($genre);
387
		} else {
388
			$trackPool = $this->trackBusinessLayer->findAll($this->userId);
389
		}
390
391
		if ($fromYear !== null) {
392
			$trackPool = \array_filter($trackPool, function ($track) use ($fromYear) {
393
				return ($track->getYear() !== null && $track->getYear() >= $fromYear);
394
			});
395
		}
396
397
		if ($toYear !== null) {
398
			$trackPool = \array_filter($trackPool, function ($track) use ($toYear) {
399
				return ($track->getYear() !== null && $track->getYear() <= $toYear);
400
			});
401
		}
402
403
		$tracks = Random::pickItems($trackPool, $size);
404
405
		return $this->subsonicResponse(['randomSongs' =>
406
				['song' => $this->tracksToApi($tracks)]
407
		]);
408
	}
409
410
	/**
411
	 * @SubsonicAPI
412
	 */
413
	protected function getCoverArt(string $id, ?int $size) {
414
		list($type, $entityId) = self::parseEntityId($id);
415
416
		if ($type == 'album') {
417
			$entity = $this->albumBusinessLayer->find($entityId, $this->userId);
418
		} elseif ($type == 'artist') {
419
			$entity = $this->artistBusinessLayer->find($entityId, $this->userId);
420
		} elseif ($type == 'podcast_channel') {
421
			$entity = $this->podcastService->getChannel($entityId, $this->userId, /*$includeEpisodes=*/ false);
422
		} elseif ($type == 'pl') {
423
			$entity = $this->playlistBusinessLayer->find($entityId, $this->userId);
424
		}
425
426
		if (!empty($entity)) {
427
			$rootFolder = $this->librarySettings->getFolder($this->userId);
428
			$coverData = $this->coverHelper->getCover($entity, $this->userId, $rootFolder, $size);
429
430
			if ($coverData === null) {
431
				$name = $entity->getNameString($this->l10n);
432
				if (\method_exists($entity, 'getAlbumArtistNameString')) {
433
					$seed = $entity->getAlbumArtistNameString($this->l10n) . $name;
434
				} else {
435
					$seed = $name;
436
				}
437
				$size = $size > 0 ? $size : $this->coverHelper->getDefaultSize();
438
				$coverData = [
439
					'content' => PlaceholderImage::generate($name, $seed, $size),
0 ignored issues
show
Bug introduced by
It seems like $size can also be of type null; however, parameter $size of OCA\Music\Utility\PlaceholderImage::generate() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

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