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

SubsonicController::getSubFoldersAndTracks()   A

Complexity

Conditions 2
Paths 1

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

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