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