Passed
Push — master ( 94efe3...da4c60 )
by Pauli
02:57
created

SubsonicController::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 42
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

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

How to fix   Many Parameters   

Many Parameters

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

There are several approaches to avoid long parameter lists:

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