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

SubsonicController::updateInternetRadioStation()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 7
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 6
c 1
b 0
f 0
dl 0
loc 7
rs 10
cc 1
nc 1
nop 4
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