Passed
Push — master ( ce9a66...c3a2b5 )
by Pauli
02:52
created

SubsonicController::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 44
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 22
nc 1
nop 21
dl 0
loc 44
rs 9.568
c 0
b 0
f 0

How to fix   Many Parameters   

Many Parameters

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

There are several approaches to avoid long parameter lists:

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