Passed
Push — master ( d62d52...23afda )
by Pauli
03:02
created

SubsonicController::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 48
Code Lines 25

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 25
c 0
b 0
f 0
nc 1
nop 22
dl 0
loc 48
rs 9.52

How to fix   Many Parameters   

Many Parameters

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

There are several approaches to avoid long parameter lists:

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

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1749
		$token = $this->imageService->getToken('artist', $id, /** @scrutinizer ignore-type */ $this->keyId);
Loading history...
1750
		return $this->urlGenerator->linkToRouteAbsolute('music.ampacheImage.image',
1751
			['object_type' => 'artist', 'object_id' => $id, 'token' => $token, 'size' => CoverHelper::DO_NOT_CROP_OR_SCALE]);
1752
	}
1753
1754
	/**
1755
	 * Given a prefixed ID like 'artist-123' or 'track-45', return the string part and the numeric part.
1756
	 * @throws SubsonicException if the \a $id doesn't follow the expected pattern
1757
	 */
1758
	private static function parseEntityId(string $id) : array {
1759
		$parts = \explode('-', $id);
1760
		if (\count($parts) !== 2) {
1761
			throw new SubsonicException("Unexpected ID format: $id", 0);
1762
		}
1763
		$parts[1] = (int)$parts[1];
1764
		return $parts;
1765
	}
1766
1767
	/**
1768
	 * Given a prefixed ID like 'artist-123' or 'track-45', return just the numeric part.
1769
	 */
1770
	private static function ripIdPrefix(string $id) : int {
1771
		return self::parseEntityId($id)[1];
1772
	}
1773
1774
	private function subsonicResponse($content, $useAttributes=true, $status = 'ok') {
1775
		$content['status'] = $status;
1776
		$content['version'] = self::API_VERSION;
1777
		$content['type'] = AppInfo::getFullName();
1778
		$content['serverVersion'] = AppInfo::getVersion();
1779
		$content['openSubsonic'] = true;
1780
		$responseData = ['subsonic-response' => Util::arrayRejectRecursive($content, 'is_null')];
1781
1782
		if ($this->format == 'json') {
1783
			$response = new JSONResponse($responseData);
1784
		} elseif ($this->format == 'jsonp') {
1785
			$responseData = \json_encode($responseData);
1786
			$response = new DataDisplayResponse("{$this->callback}($responseData);");
1787
			$response->addHeader('Content-Type', 'text/javascript; charset=UTF-8');
1788
		} else {
1789
			if (\is_array($useAttributes)) {
1790
				$useAttributes = \array_merge($useAttributes, ['status', 'version', 'type', 'serverVersion', 'xmlns']);
1791
			}
1792
			$responseData['subsonic-response']['xmlns'] = 'http://subsonic.org/restapi';
1793
			$response = new XmlResponse($responseData, $useAttributes);
1794
		}
1795
1796
		return $response;
1797
	}
1798
1799
	private static function setClientCaching(Response &$httpResponse, int $days=365) : void {
1800
		$httpResponse->cacheFor($days * 24 * 60 * 60);
1801
		$httpResponse->addHeader('Pragma', 'cache');
1802
	}
1803
1804
	public function subsonicErrorResponse($errorCode, $errorMessage) {
1805
		return $this->subsonicResponse([
1806
				'error' => [
1807
					'code' => $errorCode,
1808
					'message' => $errorMessage
1809
				]
1810
			], true, 'failed');
1811
	}
1812
}
1813