SubsonicController::getFilesystemNode()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

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