Passed
Push — feature/909_Ampache_API_improv... ( a4bfe6...150565 )
by Pauli
03:48
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 - 2023
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 = PlaceholderImage::generateForResponse($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\Placeh...::generateForResponse() 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

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