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

SubsonicController::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 44
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 1
eloc 21
nc 1
nop 22
dl 0
loc 44
rs 9.584
c 2
b 0
f 0

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