Passed
Push — master ( 2be3f6...c25ba4 )
by Pauli
03:39
created

SubsonicController::savePlayQueueByIndex()   A

Complexity

Conditions 5
Paths 3

Size

Total Lines 24
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

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