Passed
Push — master ( 6c6ee2...3c163a )
by Pauli
02:21
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 - 2022
11
 */
12
13
namespace OCA\Music\Controller;
14
15
use OCP\AppFramework\Controller;
16
use OCP\AppFramework\Http\DataDisplayResponse;
17
use OCP\AppFramework\Http\JSONResponse;
18
use OCP\AppFramework\Http\RedirectResponse;
19
use OCP\Files\File;
20
use OCP\Files\Folder;
21
use OCP\IRequest;
22
use OCP\IUserManager;
23
use OCP\IURLGenerator;
24
25
use OCA\Music\AppFramework\BusinessLayer\BusinessLayerException;
26
use OCA\Music\AppFramework\Core\Logger;
27
use OCA\Music\AppFramework\Utility\MethodAnnotationReader;
28
use OCA\Music\AppFramework\Utility\RequestParameterExtractor;
29
use OCA\Music\AppFramework\Utility\RequestParameterExtractorException;
30
31
use OCA\Music\BusinessLayer\AlbumBusinessLayer;
32
use OCA\Music\BusinessLayer\ArtistBusinessLayer;
33
use OCA\Music\BusinessLayer\BookmarkBusinessLayer;
34
use OCA\Music\BusinessLayer\GenreBusinessLayer;
35
use OCA\Music\BusinessLayer\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 nameWithoutArticle(?string $name, array $ignoredArticles) : ?string {
1055
		foreach ($ignoredArticles as $article) {
1056
			if (!empty($name) && Util::startsWith($name, $article . ' ', /*ignore_case=*/true)) {
1057
				return \substr($name, \strlen($article) + 1);
1058
			}
1059
		}
1060
		return $name;
1061
	}
1062
1063
	private static function getIndexingChar(?string $name) {
1064
		// For unknown artists, use '?'
1065
		$char = '?';
1066
1067
		if (!empty($name)) {
1068
			$char = \mb_convert_case(\mb_substr($name, 0, 1), MB_CASE_UPPER);
1069
		}
1070
		// Bundle all numeric characters together
1071
		if (\is_numeric($char)) {
1072
			$char = '#';
1073
		}
1074
1075
		return $char;
1076
	}
1077
1078
	private function getSubfoldersAndTracks(Folder $folder) : array {
1079
		$nodes = $folder->getDirectoryListing();
1080
		$subFolders = \array_filter($nodes, function ($n) {
1081
			return ($n instanceof Folder) && $this->librarySettings->pathBelongsToMusicLibrary($n->getPath(), $this->userId);
1082
		});
1083
1084
		$tracks = $this->trackBusinessLayer->findAllByFolder($folder->getId(), $this->userId);
1085
1086
		return [$subFolders, $tracks];
1087
	}
1088
1089
	private function getIndexesForFolders() {
1090
		$ignoredArticles = $this->librarySettings->getIgnoredArticles($this->userId);
1091
		$rootFolder = $this->librarySettings->getFolder($this->userId);
1092
1093
		list($subFolders, $tracks) = $this->getSubfoldersAndTracks($rootFolder);
1094
1095
		$indexes = [];
1096
		foreach ($subFolders as $folder) {
1097
			$sortName = self::nameWithoutArticle($folder->getName(), $ignoredArticles);
1098
			$indexes[self::getIndexingChar($sortName)][] = [
1099
				'sortName' => $sortName,
1100
				'artist' => [
1101
					'name' => $folder->getName(),
1102
					'id' => 'folder-' . $folder->getId()
1103
				]
1104
			];
1105
		}
1106
		\ksort($indexes, SORT_LOCALE_STRING);
1107
1108
		$folders = [];
1109
		foreach ($indexes as $indexChar => $bucketArtists) {
1110
			Util::arraySortByColumn($bucketArtists, 'sortName');
1111
			$folders[] = ['name' => $indexChar, 'artist' => \array_column($bucketArtists, 'artist')];
1112
		}
1113
1114
		return $this->subsonicResponse(['indexes' => [
1115
			'ignoredArticles' => \implode(' ', $ignoredArticles),
1116
			'index' => $folders,
1117
			'child' => $this->tracksToApi($tracks)
1118
		]]);
1119
	}
1120
1121
	private function getMusicDirectoryForFolder($id) {
1122
		$folderId = self::ripIdPrefix($id); // get rid of 'folder-' prefix
1123
		$folder = $this->getFilesystemNode($folderId);
1124
1125
		if (!($folder instanceof Folder)) {
1126
			throw new SubsonicException("$id is not a valid folder", 70);
1127
		}
1128
1129
		list($subFolders, $tracks) = $this->getSubfoldersAndTracks($folder);
1130
1131
		$children = \array_merge(
1132
			\array_map([$this, 'folderToApi'], $subFolders),
1133
			$this->tracksToApi($tracks)
1134
		);
1135
1136
		$content = [
1137
			'directory' => [
1138
				'id' => $id,
1139
				'name' => $folder->getName(),
1140
				'child' => $children
1141
			]
1142
		];
1143
1144
		// Parent folder ID is included if and only if the parent folder is not the top level
1145
		$rootFolderId = $this->librarySettings->getFolder($this->userId)->getId();
1146
		$parentFolderId = $folder->getParent()->getId();
1147
		if ($rootFolderId != $parentFolderId) {
1148
			$content['parent'] = 'folder-' . $parentFolderId;
1149
		}
1150
1151
		return $this->subsonicResponse($content);
1152
	}
1153
1154
	private function getIndexesForArtists($rootElementName = 'indexes') {
1155
		$ignoredArticles = $this->librarySettings->getIgnoredArticles($this->userId);
1156
		$artists = $this->artistBusinessLayer->findAllHavingAlbums($this->userId, SortBy::Name);
1157
1158
		$indexes = [];
1159
		foreach ($artists as $artist) {
1160
			$sortName = self::nameWithoutArticle($artist->getName(), $ignoredArticles);
1161
			$indexes[self::getIndexingChar($sortName)][] = ['sortName' => $sortName, 'artist' => $this->artistToApi($artist)];
1162
		}
1163
		\ksort($indexes, SORT_LOCALE_STRING);
1164
1165
		$result = [];
1166
		foreach ($indexes as $indexChar => $bucketArtists) {
1167
			Util::arraySortByColumn($bucketArtists, 'sortName');
1168
			$result[] = ['name' => $indexChar, 'artist' => \array_column($bucketArtists, 'artist')];
1169
		}
1170
1171
		return $this->subsonicResponse([$rootElementName => [
1172
			'ignoredArticles' => \implode(' ', $ignoredArticles),
1173
			'index' => $result
1174
		]]);
1175
	}
1176
1177
	private function getMusicDirectoryForArtist($id) {
1178
		$artistId = self::ripIdPrefix($id); // get rid of 'artist-' prefix
1179
1180
		$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
1181
		$albums = $this->albumBusinessLayer->findAllByArtist($artistId, $this->userId);
1182
1183
		return $this->subsonicResponse([
1184
			'directory' => [
1185
				'id' => $id,
1186
				'name' => $artist->getNameString($this->l10n),
1187
				'child' => \array_map([$this, 'albumToOldApi'], $albums)
1188
			]
1189
		]);
1190
	}
1191
1192
	private function getMusicDirectoryForAlbum($id) {
1193
		$albumId = self::ripIdPrefix($id); // get rid of 'album-' prefix
1194
1195
		$album = $this->albumBusinessLayer->find($albumId, $this->userId);
1196
		$albumName = $album->getNameString($this->l10n);
1197
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $this->userId);
1198
1199
		return $this->subsonicResponse([
1200
			'directory' => [
1201
				'id' => $id,
1202
				'parent' => 'artist-' . $album->getAlbumArtistId(),
1203
				'name' => $albumName,
1204
				'child' => \array_map(function ($track) use ($album) {
1205
					$track->setAlbum($album);
1206
					return $track->toSubsonicApi($this->l10n);
1207
				}, $tracks)
1208
			]
1209
		]);
1210
	}
1211
1212
	private function getMusicDirectoryForPodcastChannel($id) {
1213
		$channelId = self::ripIdPrefix($id); // get rid of 'podcast_channel-' prefix
1214
		$channel = $this->podcastService->getChannel($channelId, $this->userId, /*$includeEpisodes=*/ true);
1215
1216
		if ($channel === null) {
1217
			throw new SubsonicException("Podcast channel $channelId not found", 0);
1218
		}
1219
1220
		return $this->subsonicResponse([
1221
			'directory' => [
1222
				'id' => $id,
1223
				'name' => $channel->getTitle(),
1224
				'child' => Util::arrayMapMethod($channel->getEpisodes(), 'toSubsonicApi')
1225
			]
1226
		]);
1227
	}
1228
1229
	/**
1230
	 * @param Folder $folder
1231
	 * @return array
1232
	 */
1233
	private function folderToApi($folder) {
1234
		return [
1235
			'id' => 'folder-' . $folder->getId(),
1236
			'title' => $folder->getName(),
1237
			'isDir' => true
1238
		];
1239
	}
1240
1241
	/**
1242
	 * @param Artist $artist
1243
	 * @return array
1244
	 */
1245
	private function artistToApi($artist) {
1246
		$id = $artist->getId();
1247
		$result = [
1248
			'name' => $artist->getNameString($this->l10n),
1249
			'id' => $id ? ('artist-' . $id) : '-1', // getArtistInfo may show artists without ID
1250
			'albumCount' => $id ? $this->albumBusinessLayer->countByArtist($id) : 0,
1251
			'starred' => Util::formatZuluDateTime($artist->getStarred())
1252
		];
1253
1254
		if (!empty($artist->getCoverFileId())) {
1255
			$result['coverArt'] = $result['id'];
1256
			$result['artistImageUrl'] = $this->artistImageUrl($result['id']);
1257
		}
1258
1259
		return $result;
1260
	}
1261
1262
	/**
1263
	 * The "old API" format is used e.g. in getMusicDirectory and getAlbumList
1264
	 */
1265
	private function albumToOldApi(Album $album) : array {
1266
		$result = $this->albumCommonApiFields($album);
1267
1268
		$result['parent'] = 'artist-' . $album->getAlbumArtistId();
1269
		$result['title'] = $album->getNameString($this->l10n);
1270
		$result['isDir'] = true;
1271
1272
		return $result;
1273
	}
1274
1275
	/**
1276
	 * The "new API" format is used e.g. in getAlbum and getAlbumList2
1277
	 */
1278
	private function albumToNewApi(Album $album) : array {
1279
		$result = $this->albumCommonApiFields($album);
1280
1281
		$result['artistId'] = 'artist-' . $album->getAlbumArtistId();
1282
		$result['name'] = $album->getNameString($this->l10n);
1283
		$result['songCount'] = $this->trackBusinessLayer->countByAlbum($album->getId());
1284
		$result['duration'] = $this->trackBusinessLayer->totalDurationOfAlbum($album->getId());
1285
1286
		return $result;
1287
	}
1288
1289
	private function albumCommonApiFields(Album $album) : array {
1290
		$genreString = \implode(', ', \array_map(function (Genre $genre) {
1291
			return $genre->getNameString($this->l10n);
1292
		}, $album->getGenres() ?? []));
1293
1294
		return [
1295
			'id' => 'album-' . $album->getId(),
1296
			'artist' => $album->getAlbumArtistNameString($this->l10n),
1297
			'created' => Util::formatZuluDateTime($album->getCreated()),
1298
			'coverArt' => empty($album->getCoverFileId()) ? null : 'album-' . $album->getId(),
1299
			'starred' => Util::formatZuluDateTime($album->getStarred()),
1300
			'year' => $album->yearToAPI(),
1301
			'genre' => $genreString ?: null
1302
		];
1303
	}
1304
1305
	/**
1306
	 * @param Track[] $tracks
1307
	 * @return array
1308
	 */
1309
	private function tracksToApi(array $tracks) : array {
1310
		$this->albumBusinessLayer->injectAlbumsToTracks($tracks, $this->userId);
1311
		return Util::arrayMapMethod($tracks, 'toSubsonicApi', [$this->l10n]);
1312
	}
1313
1314
	/**
1315
	 * Common logic for getAlbumList and getAlbumList2
1316
	 * @return Album[]
1317
	 */
1318
	private function albumsForGetAlbumList(
1319
			string $type, ?string $genre, ?int $fromYear, ?int $toYear, int $size, int $offset) : array {
1320
		$size = \min($size, 500); // the API spec limits the maximum amount to 500
1321
1322
		$albums = [];
1323
1324
		switch ($type) {
1325
			case 'random':
1326
				$allAlbums = $this->albumBusinessLayer->findAll($this->userId);
1327
				$indices = $this->random->getIndices(\count($allAlbums), $offset, $size, $this->userId, 'subsonic_albums');
1328
				$albums = Util::arrayMultiGet($allAlbums, $indices);
1329
				break;
1330
			case 'starred':
1331
				$albums = $this->albumBusinessLayer->findAllStarred($this->userId, $size, $offset);
1332
				break;
1333
			case 'alphabeticalByName':
1334
				$albums = $this->albumBusinessLayer->findAll($this->userId, SortBy::Name, $size, $offset);
1335
				break;
1336
			case 'alphabeticalByArtist':
1337
				$albums = $this->albumBusinessLayer->findAll($this->userId, SortBy::Parent, $size, $offset);
1338
				break;
1339
			case 'byGenre':
1340
				self::ensureParamHasValue('genre', $genre);
1341
				$albums = $this->findAlbumsByGenre($genre, $size, $offset);
1342
				break;
1343
			case 'byYear':
1344
				self::ensureParamHasValue('fromYear', $fromYear);
1345
				self::ensureParamHasValue('toYear', $toYear);
1346
				$albums = $this->albumBusinessLayer->findAllByYearRange($fromYear, $toYear, $this->userId, $size, $offset);
1347
				break;
1348
			case 'newest':
1349
				$albums = $this->albumBusinessLayer->findAll($this->userId, SortBy::Newest, $size, $offset);
1350
				break;
1351
			case 'frequent':
1352
				$albums = $this->albumBusinessLayer->findFrequentPlay($this->userId, $size, $offset);
1353
				break;
1354
			case 'recent':
1355
				$albums = $this->albumBusinessLayer->findRecentPlay($this->userId, $size, $offset);
1356
				break;
1357
			case 'highest':
1358
				// TODO
1359
			default:
1360
				$this->logger->log("Album list type '$type' is not supported", 'debug');
1361
				break;
1362
		}
1363
1364
		return $albums;
1365
	}
1366
1367
	/**
1368
	 * Given any entity ID like 'track-123' or 'album-2' or 'artist-3' or 'folder-4', return the matching
1369
	 * numeric artist identifier if possible (may be e.g. performer of the track or album, or an artist
1370
	 * with a name matching the folder name)
1371
	 */
1372
	private function getArtistIdFromEntityId(string $entityId) : ?int {
1373
		list($type, $id) = self::parseEntityId($entityId);
1374
1375
		switch ($type) {
1376
			case 'artist':
1377
				return $id;
1378
			case 'album':
1379
				return $this->albumBusinessLayer->find($id, $this->userId)->getAlbumArtistId();
1380
			case 'track':
1381
				return $this->trackBusinessLayer->find($id, $this->userId)->getArtistId();
1382
			case 'folder':
1383
				$folder = $this->librarySettings->getFolder($this->userId)->getById($id)[0] ?? null;
1384
				if ($folder !== null) {
1385
					$artist = $this->artistBusinessLayer->findAllByName($folder->getName(), $this->userId)[0] ?? null;
1386
					if ($artist !== null) {
1387
						return $artist->getId();
1388
					}
1389
				}
1390
				break;
1391
		}
1392
1393
		return null;
1394
	}
1395
1396
	/**
1397
	 * Common logic for getArtistInfo and getArtistInfo2
1398
	 */
1399
	private function doGetArtistInfo(string $rootName, string $id, bool $includeNotPresent) {
1400
		$content = [];
1401
1402
		$artistId = $this->getArtistIdFromEntityId($id);
1403
		if ($artistId !== null) {
1404
			$info = $this->lastfmService->getArtistInfo($artistId, $this->userId);
1405
1406
			if (isset($info['artist'])) {
1407
				$content = [
1408
					'biography' => $info['artist']['bio']['summary'],
1409
					'lastFmUrl' => $info['artist']['url'],
1410
					'musicBrainzId' => $info['artist']['mbid'] ?? null
1411
				];
1412
1413
				$similarArtists = $this->lastfmService->getSimilarArtists($artistId, $this->userId, $includeNotPresent);
1414
				$content['similarArtist'] = \array_map([$this, 'artistToApi'], $similarArtists);
1415
			}
1416
1417
			$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
1418
			if ($artist->getCoverFileId() !== null) {
1419
				$content['largeImageUrl'] = [$this->artistImageUrl('artist-' . $artistId)];
1420
			}
1421
		}
1422
1423
		// This method is unusual in how it uses non-attribute elements in the response. On the other hand,
1424
		// all the details of the <similarArtist> elements are rendered as attributes. List those separately.
1425
		$attributeKeys = ['name', 'id', 'albumCount', 'coverArt', 'artistImageUrl', 'starred'];
1426
1427
		return $this->subsonicResponse([$rootName => $content], $attributeKeys);
1428
	}
1429
1430
	/**
1431
	 * Given any entity ID like 'track-123' or 'album-2' or 'folder-4', return the matching numeric
1432
	 * album identifier if possible (may be e.g. host album of the track or album with a name
1433
	 * matching the folder name)
1434
	 */
1435
	private function getAlbumIdFromEntityId(string $entityId) : ?int {
1436
		list($type, $id) = self::parseEntityId($entityId);
1437
1438
		switch ($type) {
1439
			case 'album':
1440
				return $id;
1441
			case 'track':
1442
				return $this->trackBusinessLayer->find($id, $this->userId)->getAlbumId();
1443
			case 'folder':
1444
				$folder = $this->librarySettings->getFolder($this->userId)->getById($id)[0] ?? null;
1445
				if ($folder !== null) {
1446
					$album = $this->albumBusinessLayer->findAllByName($folder->getName(), $this->userId)[0] ?? null;
1447
					if ($album !== null) {
1448
						return $album->getId();
1449
					}
1450
				}
1451
				break;
1452
		}
1453
1454
		return null;
1455
	}
1456
	/**
1457
	 * Common logic for getAlbumInfo and getAlbumInfo2
1458
	 */
1459
	private function doGetAlbumInfo(string $rootName, string $id) {
1460
		$content = [];
1461
1462
		$albumId = $this->getAlbumIdFromEntityId($id);
1463
		if ($albumId !== null) {
1464
			$info = $this->lastfmService->getAlbumInfo($albumId, $this->userId);
1465
1466
			if (isset($info['album'])) {
1467
				$content = [
1468
					'notes' => $info['album']['wiki']['summary'] ?? null,
1469
					'lastFmUrl' => $info['album']['url'],
1470
					'musicBrainzId' => $info['album']['mbid'] ?? null
1471
				];
1472
1473
				foreach ($info['album']['image'] ?? [] as $imageInfo) {
1474
					if (!empty($imageInfo['size'])) {
1475
						$content[$imageInfo['size'] . 'ImageUrl'] = $imageInfo['#text'];
1476
					}
1477
				}
1478
			}
1479
		}
1480
1481
		// This method is unusual in how it uses non-attribute elements in the response.
1482
		return $this->subsonicResponse([$rootName => $content], []);
1483
	}
1484
1485
	/**
1486
	 * Common logic for getSimilarSongs and getSimilarSongs2
1487
	 */
1488
	private function doGetSimilarSongs(string $rootName, string $id, int $count) {
1489
		if (Util::startsWith($id, 'artist')) {
1490
			$artistId = self::ripIdPrefix($id);
1491
		} elseif (Util::startsWith($id, 'album')) {
1492
			$albumId = self::ripIdPrefix($id);
1493
			$artistId = $this->albumBusinessLayer->find($albumId, $this->userId)->getAlbumArtistId();
1494
		} elseif (Util::startsWith($id, 'track')) {
1495
			$trackId = self::ripIdPrefix($id);
1496
			$artistId = $this->trackBusinessLayer->find($trackId, $this->userId)->getArtistId();
1497
		} else {
1498
			throw new SubsonicException("Id $id has a type not supported on getSimilarSongs", 0);
1499
		}
1500
1501
		$artists = $this->lastfmService->getSimilarArtists($artistId, $this->userId);
1502
		$artists[] = $this->artistBusinessLayer->find($artistId, $this->userId);
1503
1504
		// Get all songs by the found artists
1505
		$songs = [];
1506
		foreach ($artists as $artist) {
1507
			$songs = \array_merge($songs, $this->trackBusinessLayer->findAllByArtist($artist->getId(), $this->userId));
1508
		}
1509
1510
		// Randomly select the desired number of songs
1511
		$songs = $this->random->pickItems($songs, $count);
1512
1513
		return $this->subsonicResponse([$rootName => [
1514
			'song' => $this->tracksToApi($songs)
1515
		]]);
1516
	}
1517
1518
	/**
1519
	 * Common logic for search2 and search3
1520
	 * @return array with keys 'artists', 'albums', and 'tracks'
1521
	 */
1522
	private function doSearch(string $query, int $artistCount, int $artistOffset,
1523
			int $albumCount, int $albumOffset, int $songCount, int $songOffset) {
1524
1525
		if (empty($query)) {
1526
			throw new SubsonicException("The 'query' argument is mandatory", 10);
1527
		}
1528
1529
		return [
1530
			'artists' => $this->artistBusinessLayer->findAllByName($query, $this->userId, MatchMode::Substring, $artistCount, $artistOffset),
1531
			'albums' => $this->albumBusinessLayer->findAllByName($query, $this->userId, MatchMode::Substring, $albumCount, $albumOffset),
1532
			'tracks' => $this->trackBusinessLayer->findAllByName($query, $this->userId, MatchMode::Substring, $songCount, $songOffset)
1533
		];
1534
	}
1535
1536
	/**
1537
	 * Common logic for getStarred and getStarred2
1538
	 * @return array
1539
	 */
1540
	private function doGetStarred() {
1541
		return [
1542
			'artists' => $this->artistBusinessLayer->findAllStarred($this->userId),
1543
			'albums' => $this->albumBusinessLayer->findAllStarred($this->userId),
1544
			'tracks' => $this->trackBusinessLayer->findAllStarred($this->userId)
1545
		];
1546
	}
1547
1548
	/**
1549
	 * Common response building logic for search2, search3, getStarred, and getStarred2
1550
	 * @param string $title Name of the main node in the response message
1551
	 * @param array $results Search results with keys 'artists', 'albums', and 'tracks'
1552
	 * @param boolean $useNewApi Set to true for search3 and getStarred2. There is a difference
1553
	 *                           in the formatting of the album nodes.
1554
	 * @return \OCP\AppFramework\Http\Response
1555
	 */
1556
	private function searchResponse($title, $results, $useNewApi) {
1557
		$albumMapFunc = $useNewApi ? 'albumToNewApi' : 'albumToOldApi';
1558
1559
		return $this->subsonicResponse([$title => [
1560
			'artist' => \array_map([$this, 'artistToApi'], $results['artists']),
1561
			'album' => \array_map([$this, $albumMapFunc], $results['albums']),
1562
			'song' => $this->tracksToApi($results['tracks'])
1563
		]]);
1564
	}
1565
1566
	/**
1567
	 * Find tracks by genre name
1568
	 * @param string $genreName
1569
	 * @param int|null $limit
1570
	 * @param int|null $offset
1571
	 * @return Track[]
1572
	 */
1573
	private function findTracksByGenre($genreName, $limit=null, $offset=null) {
1574
		$genre = $this->findGenreByName($genreName);
1575
1576
		if ($genre) {
1577
			return $this->trackBusinessLayer->findAllByGenre($genre->getId(), $this->userId, $limit, $offset);
1578
		} else {
1579
			return [];
1580
		}
1581
	}
1582
1583
	/**
1584
	 * Find albums by genre name
1585
	 * @param string $genreName
1586
	 * @param int|null $limit
1587
	 * @param int|null $offset
1588
	 * @return Album[]
1589
	 */
1590
	private function findAlbumsByGenre($genreName, $limit=null, $offset=null) {
1591
		$genre = $this->findGenreByName($genreName);
1592
1593
		if ($genre) {
1594
			return $this->albumBusinessLayer->findAllByGenre($genre->getId(), $this->userId, $limit, $offset);
1595
		} else {
1596
			return [];
1597
		}
1598
	}
1599
1600
	private function findGenreByName($name) {
1601
		$genreArr = $this->genreBusinessLayer->findAllByName($name, $this->userId);
1602
		if (\count($genreArr) == 0 && $name == Genre::unknownNameString($this->l10n)) {
1603
			$genreArr = $this->genreBusinessLayer->findAllByName('', $this->userId);
1604
		}
1605
		return \count($genreArr) ? $genreArr[0] : null;
1606
	}
1607
1608
	private function artistImageUrl(string $id) : string {
1609
		$par = $this->request->getParams();
1610
		return $this->urlGenerator->linkToRouteAbsolute('music.subsonic.handleRequest', ['method' => 'getCoverArt'])
1611
			. "?u={$par['u']}&p={$par['p']}&v={$par['v']}&c={$par['c']}&id=$id&size=" . CoverHelper::DO_NOT_CROP_OR_SCALE;
1612
		// Note: Using DO_NOT_CROP_OR_SCALE (-1) as size is our proprietary extension and not part of the Subsonic API
1613
	}
1614
1615
	/**
1616
	 * Given a prefixed ID like 'artist-123' or 'track-45', return the string part and the numeric part.
1617
	 * @throws SubsonicException if the \a $id doesn't follow the expected pattern
1618
	 */
1619
	private static function parseEntityId(string $id) : array {
1620
		$parts = \explode('-', $id);
1621
		if (\count($parts) !== 2) {
1622
			throw new SubsonicException("Unexpected ID format: $id", 0);
1623
		}
1624
		$parts[1] = (int)$parts[1];
1625
		return $parts;
1626
	}
1627
1628
	/**
1629
	 * Given a prefixed ID like 'artist-123' or 'track-45', return just the numeric part.
1630
	 */
1631
	private static function ripIdPrefix(string $id) : int {
1632
		return self::parseEntityId($id)[1];
1633
	}
1634
1635
	private function subsonicResponse($content, $useAttributes=true, $status = 'ok') {
1636
		$content['status'] = $status;
1637
		$content['version'] = self::API_VERSION;
1638
		$responseData = ['subsonic-response' => Util::arrayRejectRecursive($content, 'is_null')];
1639
1640
		if ($this->format == 'json') {
1641
			$response = new JSONResponse($responseData);
1642
		} elseif ($this->format == 'jsonp') {
1643
			$responseData = \json_encode($responseData);
1644
			$response = new DataDisplayResponse("{$this->callback}($responseData);");
1645
			$response->addHeader('Content-Type', 'text/javascript; charset=UTF-8');
1646
		} else {
1647
			if (\is_array($useAttributes)) {
1648
				$useAttributes = \array_merge($useAttributes, ['status', 'version', 'xmlns']);
1649
			}
1650
			$responseData['subsonic-response']['xmlns'] = 'http://subsonic.org/restapi';
1651
			$response = new XmlResponse($responseData, $useAttributes);
1652
		}
1653
1654
		return $response;
1655
	}
1656
1657
	public function subsonicErrorResponse($errorCode, $errorMessage) {
1658
		return $this->subsonicResponse([
1659
				'error' => [
1660
					'code' => $errorCode,
1661
					'message' => $errorMessage
1662
				]
1663
			], true, 'failed');
1664
	}
1665
}
1666