Passed
Push — master ( 012442...4c4644 )
by Pauli
03:10 queued 15s
created

SubsonicController::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 45
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Importance

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