Completed
Push — master ( 2a0431...b23a56 )
by Pauli
20s queued 15s
created

SubsonicController::getNowPlaying()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 0
dl 0
loc 3
rs 10
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 - 2022
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\IRequest;
22
use OCP\IUserManager;
23
use OCP\IURLGenerator;
24
25
use OCA\Music\AppFramework\BusinessLayer\BusinessLayerException;
26
use OCA\Music\AppFramework\Core\Logger;
27
use OCA\Music\AppFramework\Utility\MethodAnnotationReader;
28
use OCA\Music\AppFramework\Utility\RequestParameterExtractor;
29
use OCA\Music\AppFramework\Utility\RequestParameterExtractorException;
30
31
use OCA\Music\BusinessLayer\AlbumBusinessLayer;
32
use OCA\Music\BusinessLayer\ArtistBusinessLayer;
33
use OCA\Music\BusinessLayer\BookmarkBusinessLayer;
34
use OCA\Music\BusinessLayer\GenreBusinessLayer;
35
use OCA\Music\BusinessLayer\PlaylistBusinessLayer;
36
use OCA\Music\BusinessLayer\PodcastChannelBusinessLayer;
37
use OCA\Music\BusinessLayer\PodcastEpisodeBusinessLayer;
38
use OCA\Music\BusinessLayer\RadioStationBusinessLayer;
39
use OCA\Music\BusinessLayer\TrackBusinessLayer;
40
41
use OCA\Music\Db\Album;
42
use OCA\Music\Db\Artist;
43
use OCA\Music\Db\Bookmark;
44
use OCA\Music\Db\Genre;
45
use OCA\Music\Db\MatchMode;
46
use OCA\Music\Db\PodcastEpisode;
47
use OCA\Music\Db\SortBy;
48
use OCA\Music\Db\Track;
49
50
use OCA\Music\Http\FileResponse;
51
use OCA\Music\Http\FileStreamResponse;
52
use OCA\Music\Http\XmlResponse;
53
54
use OCA\Music\Middleware\SubsonicException;
55
56
use OCA\Music\Utility\CoverHelper;
57
use OCA\Music\Utility\DetailsHelper;
58
use OCA\Music\Utility\LastfmService;
59
use OCA\Music\Utility\LibrarySettings;
60
use OCA\Music\Utility\PodcastService;
61
use OCA\Music\Utility\Random;
62
use OCA\Music\Utility\Util;
63
use OCA\Music\Utility\PlaceholderImage;
64
65
class SubsonicController extends Controller {
66
	const API_VERSION = '1.16.1';
67
68
	private $albumBusinessLayer;
69
	private $artistBusinessLayer;
70
	private $bookmarkBusinessLayer;
71
	private $genreBusinessLayer;
72
	private $playlistBusinessLayer;
73
	private $podcastChannelBusinessLayer;
74
	private $podcastEpisodeBusinessLayer;
75
	private $radioStationBusinessLayer;
76
	private $trackBusinessLayer;
77
	private $urlGenerator;
78
	private $userManager;
79
	private $librarySettings;
80
	private $l10n;
81
	private $coverHelper;
82
	private $detailsHelper;
83
	private $lastfmService;
84
	private $podcastService;
85
	private $random;
86
	private $logger;
87
	private $userId;
88
	private $format;
89
	private $callback;
90
91
	public function __construct(string $appname,
92
								IRequest $request,
93
								$l10n,
94
								IURLGenerator $urlGenerator,
95
								IUserManager $userManager,
96
								AlbumBusinessLayer $albumBusinessLayer,
97
								ArtistBusinessLayer $artistBusinessLayer,
98
								BookmarkBusinessLayer $bookmarkBusinessLayer,
99
								GenreBusinessLayer $genreBusinessLayer,
100
								PlaylistBusinessLayer $playlistBusinessLayer,
101
								PodcastChannelBusinessLayer $podcastChannelBusinessLayer,
102
								PodcastEpisodeBusinessLayer $podcastEpisodeBusinessLayer,
103
								RadioStationBusinessLayer $radioStationBusinessLayer,
104
								TrackBusinessLayer $trackBusinessLayer,
105
								LibrarySettings $librarySettings,
106
								CoverHelper $coverHelper,
107
								DetailsHelper $detailsHelper,
108
								LastfmService $lastfmService,
109
								PodcastService $podcastService,
110
								Random $random,
111
								Logger $logger) {
112
		parent::__construct($appname, $request);
113
114
		$this->albumBusinessLayer = $albumBusinessLayer;
115
		$this->artistBusinessLayer = $artistBusinessLayer;
116
		$this->bookmarkBusinessLayer = $bookmarkBusinessLayer;
117
		$this->genreBusinessLayer = $genreBusinessLayer;
118
		$this->playlistBusinessLayer = $playlistBusinessLayer;
119
		$this->podcastChannelBusinessLayer = $podcastChannelBusinessLayer;
120
		$this->podcastEpisodeBusinessLayer = $podcastEpisodeBusinessLayer;
121
		$this->radioStationBusinessLayer = $radioStationBusinessLayer;
122
		$this->trackBusinessLayer = $trackBusinessLayer;
123
		$this->urlGenerator = $urlGenerator;
124
		$this->userManager = $userManager;
125
		$this->l10n = $l10n;
126
		$this->librarySettings = $librarySettings;
127
		$this->coverHelper = $coverHelper;
128
		$this->detailsHelper = $detailsHelper;
129
		$this->lastfmService = $lastfmService;
130
		$this->podcastService = $podcastService;
131
		$this->random = $random;
132
		$this->logger = $logger;
133
	}
134
135
	/**
136
	 * Called by the middleware to set the reponse format to be used
137
	 * @param string $format Response format: xml/json/jsonp
138
	 * @param string|null $callback Function name to use if the @a $format is 'jsonp'
139
	 */
140
	public function setResponseFormat(string $format, string $callback = null) {
141
		$this->format = $format;
142
		$this->callback = $callback;
143
	}
144
145
	/**
146
	 * Called by the middleware once the user credentials have been checked
147
	 * @param string $userId
148
	 */
149
	public function setAuthenticatedUser(string $userId) {
150
		$this->userId = $userId;
151
	}
152
153
	/**
154
	 * @NoAdminRequired
155
	 * @PublicPage
156
	 * @NoCSRFRequired
157
	 * @NoSameSiteCookieRequired
158
	 */
159
	public function handleRequest($method) {
160
		$this->logger->log("Subsonic request $method", 'debug');
161
162
		// Allow calling all methods with or without the postfix ".view"
163
		if (Util::endsWith($method, ".view")) {
164
			$method = \substr($method, 0, -\strlen(".view"));
165
		}
166
167
		// Allow calling any functions annotated to be part of the API
168
		if (\method_exists($this, $method)) {
169
			$annotationReader = new MethodAnnotationReader($this, $method);
170
			if ($annotationReader->hasAnnotation('SubsonicAPI')) {
171
				$parameterExtractor = new RequestParameterExtractor($this->request);
172
				try {
173
					$parameterValues = $parameterExtractor->getParametersForMethod($this, $method);
174
				} catch (RequestParameterExtractorException $ex) {
175
					return $this->subsonicErrorResponse(10, $ex->getMessage());
176
				}
177
				return \call_user_func_array([$this, $method], $parameterValues);
178
			}
179
		}
180
181
		$this->logger->log("Request $method not supported", 'warn');
182
		return $this->subsonicErrorResponse(70, "Requested action $method is not supported");
183
	}
184
185
	/* -------------------------------------------------------------------------
186
	 * REST API methods
187
	 *------------------------------------------------------------------------*/
188
189
	/**
190
	 * @SubsonicAPI
191
	 */
192
	protected function ping() {
193
		return $this->subsonicResponse([]);
194
	}
195
196
	/**
197
	 * @SubsonicAPI
198
	 */
199
	protected function getLicense() {
200
		return $this->subsonicResponse([
201
			'license' => [
202
				'valid' => 'true'
203
			]
204
		]);
205
	}
206
207
	/**
208
	 * @SubsonicAPI
209
	 */
210
	protected function getMusicFolders() {
211
		// Only single root folder is supported
212
		return $this->subsonicResponse([
213
			'musicFolders' => ['musicFolder' => [
214
				['id' => 'artists', 'name' => $this->l10n->t('Artists')],
215
				['id' => 'folders', 'name' => $this->l10n->t('Folders')]
216
			]]
217
		]);
218
	}
219
220
	/**
221
	 * @SubsonicAPI
222
	 */
223
	protected function getIndexes(?string $musicFolderId) {
224
		if ($musicFolderId === 'folders') {
225
			return $this->getIndexesForFolders();
226
		} else {
227
			return $this->getIndexesForArtists();
228
		}
229
	}
230
231
	/**
232
	 * @SubsonicAPI
233
	 */
234
	protected function getMusicDirectory(string $id) {
235
		if (Util::startsWith($id, 'folder-')) {
236
			return $this->getMusicDirectoryForFolder($id);
237
		} elseif (Util::startsWith($id, 'artist-')) {
238
			return $this->getMusicDirectoryForArtist($id);
239
		} elseif (Util::startsWith($id, 'album-')) {
240
			return $this->getMusicDirectoryForAlbum($id);
241
		} elseif (Util::startsWith($id, 'podcast_channel-')) {
242
			return $this->getMusicDirectoryForPodcastChannel($id);
243
		} else {
244
			throw new SubsonicException("Unsupported id format $id");
245
		}
246
	}
247
248
	/**
249
	 * @SubsonicAPI
250
	 */
251
	protected function getAlbumList(
252
			string $type, ?string $genre, ?int $fromYear, ?int $toYear, int $size=10, int $offset=0) {
253
		$albums = $this->albumsForGetAlbumList($type, $genre, $fromYear, $toYear, $size, $offset);
254
		return $this->subsonicResponse(['albumList' =>
255
				['album' => \array_map([$this, 'albumToOldApi'], $albums)]
256
		]);
257
	}
258
259
	/**
260
	 * @SubsonicAPI
261
	 */
262
	protected function getAlbumList2(
263
			string $type, ?string $genre, ?int $fromYear, ?int $toYear, int $size=10, int $offset=0) {
264
		/*
265
		 * According to the API specification, the difference between this and getAlbumList
266
		 * should be that this function would organize albums according the metadata while
267
		 * getAlbumList would organize them by folders. However, we organize by metadata
268
		 * also in getAlbumList, because that's more natural for the Music app and many/most
269
		 * clients do not support getAlbumList2.
270
		 */
271
		$albums = $this->albumsForGetAlbumList($type, $genre, $fromYear, $toYear, $size, $offset);
272
		return $this->subsonicResponse(['albumList2' =>
273
				['album' => \array_map([$this, 'albumToNewApi'], $albums)]
274
		]);
275
	}
276
277
	/**
278
	 * @SubsonicAPI
279
	 */
280
	protected function getArtists() {
281
		return $this->getIndexesForArtists('artists');
282
	}
283
284
	/**
285
	 * @SubsonicAPI
286
	 */
287
	protected function getArtist(string $id) {
288
		$artistId = self::ripIdPrefix($id); // get rid of 'artist-' prefix
289
290
		$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
291
		$albums = $this->albumBusinessLayer->findAllByArtist($artistId, $this->userId);
292
293
		$artistNode = $this->artistToApi($artist);
294
		$artistNode['album'] = \array_map([$this, 'albumToNewApi'], $albums);
295
296
		return $this->subsonicResponse(['artist' => $artistNode]);
297
	}
298
299
	/**
300
	 * @SubsonicAPI
301
	 */
302
	protected function getArtistInfo(string $id, bool $includeNotPresent=false) {
303
		return $this->doGetArtistInfo('artistInfo', $id, $includeNotPresent);
304
	}
305
306
	/**
307
	 * @SubsonicAPI
308
	 */
309
	protected function getArtistInfo2(string $id, bool $includeNotPresent=false) {
310
		return $this->doGetArtistInfo('artistInfo2', $id, $includeNotPresent);
311
	}
312
313
	/**
314
	 * @SubsonicAPI
315
	 */
316
	protected function getAlbumInfo(string $id) {
317
		return $this->doGetAlbumInfo('albumInfo', $id);
318
	}
319
320
	/**
321
	 * @SubsonicAPI
322
	 */
323
	protected function getAlbumInfo2(string $id) {
324
		return $this->doGetAlbumInfo('albumInfo2', $id);
325
	}
326
327
	/**
328
	 * @SubsonicAPI
329
	 */
330
	protected function getSimilarSongs(string $id, int $count=50) {
331
		return $this->doGetSimilarSongs('similarSongs', $id, $count);
332
	}
333
334
	/**
335
	 * @SubsonicAPI
336
	 */
337
	protected function getSimilarSongs2(string $id, int $count=50) {
338
		return $this->doGetSimilarSongs('similarSongs2', $id, $count);
339
	}
340
341
	/**
342
	 * @SubsonicAPI
343
	 */
344
	protected function getTopSongs(string $artist, int $count=50) {
345
		$tracks = $this->lastfmService->getTopTracks($artist, $this->userId, $count);
346
		return $this->subsonicResponse(['topSongs' =>
347
			['song' => $this->tracksToApi($tracks)]
348
		]);
349
	}
350
351
	/**
352
	 * @SubsonicAPI
353
	 */
354
	protected function getAlbum(string $id) {
355
		$albumId = self::ripIdPrefix($id); // get rid of 'album-' prefix
356
357
		$album = $this->albumBusinessLayer->find($albumId, $this->userId);
358
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $this->userId);
359
360
		$albumNode = $this->albumToNewApi($album);
361
		$albumNode['song'] = \array_map(function ($track) use ($album) {
362
			$track->setAlbum($album);
363
			return $track->toSubsonicApi($this->l10n);
364
		}, $tracks);
365
		return $this->subsonicResponse(['album' => $albumNode]);
366
	}
367
368
	/**
369
	 * @SubsonicAPI
370
	 */
371
	protected function getSong(string $id) {
372
		$trackId = self::ripIdPrefix($id); // get rid of 'track-' prefix
373
		$track = $this->trackBusinessLayer->find($trackId, $this->userId);
374
		$track->setAlbum($this->albumBusinessLayer->find($track->getAlbumId(), $this->userId));
375
376
		return $this->subsonicResponse(['song' => $track->toSubsonicApi($this->l10n)]);
377
	}
378
379
	/**
380
	 * @SubsonicAPI
381
	 */
382
	protected function getRandomSongs(?string $genre, ?string $fromYear, ?string $toYear, int $size=10) {
383
		$size = \min($size, 500); // the API spec limits the maximum amount to 500
384
385
		if ($genre !== null) {
386
			$trackPool = $this->findTracksByGenre($genre);
387
		} else {
388
			$trackPool = $this->trackBusinessLayer->findAll($this->userId);
389
		}
390
391
		if ($fromYear !== null) {
392
			$trackPool = \array_filter($trackPool, function ($track) use ($fromYear) {
393
				return ($track->getYear() !== null && $track->getYear() >= $fromYear);
394
			});
395
		}
396
397
		if ($toYear !== null) {
398
			$trackPool = \array_filter($trackPool, function ($track) use ($toYear) {
399
				return ($track->getYear() !== null && $track->getYear() <= $toYear);
400
			});
401
		}
402
403
		$tracks = Random::pickItems($trackPool, $size);
404
405
		return $this->subsonicResponse(['randomSongs' =>
406
				['song' => $this->tracksToApi($tracks)]
407
		]);
408
	}
409
410
	/**
411
	 * @SubsonicAPI
412
	 */
413
	protected function getCoverArt(string $id, ?int $size) {
414
		list($type, $entityId) = self::parseEntityId($id);
415
416
		if ($type == 'album') {
417
			$entity = $this->albumBusinessLayer->find($entityId, $this->userId);
418
		} elseif ($type == 'artist') {
419
			$entity = $this->artistBusinessLayer->find($entityId, $this->userId);
420
		} elseif ($type == 'podcast_channel') {
421
			$entity = $this->podcastService->getChannel($entityId, $this->userId, /*$includeEpisodes=*/ false);
422
		} elseif ($type == 'pl') {
423
			$entity = $this->playlistBusinessLayer->find($entityId, $this->userId);
424
		}
425
426
		if (!empty($entity)) {
427
			$rootFolder = $this->librarySettings->getFolder($this->userId);
428
			$coverData = $this->coverHelper->getCover($entity, $this->userId, $rootFolder, $size);
429
430
			if ($coverData === null) {
431
				$name = $entity->getNameString($this->l10n);
432
				if (\method_exists($entity, 'getAlbumArtistNameString')) {
433
					$seed = $entity->getAlbumArtistNameString($this->l10n) . $name;
434
				} else {
435
					$seed = $name;
436
				}
437
				$size = $size > 0 ? $size : $this->coverHelper->getDefaultSize();
438
				$coverData = [
439
					'content' => PlaceholderImage::generate($name, $seed, $size),
0 ignored issues
show
Bug introduced by
It seems like $size can also be of type null; however, parameter $size of OCA\Music\Utility\PlaceholderImage::generate() 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

439
					'content' => PlaceholderImage::generate($name, $seed, /** @scrutinizer ignore-type */ $size),
Loading history...
440
					'mimetype' => 'image/png'
441
				];
442
			}
443
444
			return new FileResponse($coverData);
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
		$matches = $this->trackBusinessLayer->findAllByNameAndArtistName($title, $artist, $this->userId);
455
		$matchingCount = \count($matches);
456
457
		if ($matchingCount === 0) {
458
			$this->logger->log("No matching track for title '$title' and artist '$artist'", 'debug');
459
			return $this->subsonicResponse(['lyrics' => new \stdClass]);
460
		} else {
461
			if ($matchingCount > 1) {
462
				$this->logger->log("Found $matchingCount tracks matching title ".
463
								"'$title' and artist '$artist'; using the first", 'debug');
464
			}
465
			$track = $matches[0];
466
467
			$artistObj = $this->artistBusinessLayer->find($track->getArtistId(), $this->userId);
468
			$rootFolder = $this->librarySettings->getFolder($this->userId);
469
			$lyrics = $this->detailsHelper->getLyrics($track->getFileId(), $rootFolder);
470
471
			return $this->subsonicResponse(['lyrics' => [
472
					'artist' => $artistObj->getNameString($this->l10n),
473
					'title' => $track->getTitle(),
474
					'value' => $lyrics
475
			]]);
476
		}
477
	}
478
479
	/**
480
	 * @SubsonicAPI
481
	 */
482
	protected function stream(string $id) {
483
		// We don't support transcaoding, so 'stream' and 'download' act identically
484
		return $this->download($id);
485
	}
486
487
	/**
488
	 * @SubsonicAPI
489
	 */
490
	protected function download(string $id) {
491
		list($type, $entityId) = self::parseEntityId($id);
492
493
		if ($type === 'track') {
494
			$track = $this->trackBusinessLayer->find($entityId, $this->userId);
495
			$file = $this->getFilesystemNode($track->getFileId());
496
497
			if ($file instanceof File) {
498
				return new FileStreamResponse($file);
499
			} else {
500
				return $this->subsonicErrorResponse(70, 'file not found');
501
			}
502
		} elseif ($type === 'podcast_episode') {
503
			$episode = $this->podcastService->getEpisode($entityId, $this->userId);
504
			if ($episode instanceof PodcastEpisode) {
505
				return new RedirectResponse($episode->getStreamUrl());
506
			} else {
507
				return $this->subsonicErrorResponse(70, 'episode not found');
508
			}
509
		} else {
510
			return $this->subsonicErrorResponse(0, "id of type $type not supported");
511
		}
512
	}
513
514
	/**
515
	 * @SubsonicAPI
516
	 */
517
	protected function search2(string $query, int $artistCount=20, int $artistOffset=0,
518
			int $albumCount=20, int $albumOffset=0, int $songCount=20, int $songOffset=0) {
519
		$results = $this->doSearch($query, $artistCount, $artistOffset, $albumCount, $albumOffset, $songCount, $songOffset);
520
		return $this->searchResponse('searchResult2', $results, /*$useNewApi=*/false);
521
	}
522
523
	/**
524
	 * @SubsonicAPI
525
	 */
526
	protected function search3(string $query, int $artistCount=20, int $artistOffset=0,
527
			int $albumCount=20, int $albumOffset=0, int $songCount=20, int $songOffset=0) {
528
		$results = $this->doSearch($query, $artistCount, $artistOffset, $albumCount, $albumOffset, $songCount, $songOffset);
529
		return $this->searchResponse('searchResult3', $results, /*$useNewApi=*/true);
530
	}
531
532
	/**
533
	 * @SubsonicAPI
534
	 */
535
	protected function getGenres() {
536
		$genres = $this->genreBusinessLayer->findAll($this->userId, SortBy::Name);
537
538
		return $this->subsonicResponse(['genres' =>
539
			[
540
				'genre' => \array_map(function ($genre) {
541
					return [
542
						'songCount' => $genre->getTrackCount(),
543
						'albumCount' => $genre->getAlbumCount(),
544
						'value' => $genre->getNameString($this->l10n)
545
					];
546
				},
547
				$genres)
548
			]
549
		]);
550
	}
551
552
	/**
553
	 * @SubsonicAPI
554
	 */
555
	protected function getSongsByGenre(string $genre, int $count=10, int $offset=0) {
556
		$tracks = $this->findTracksByGenre($genre, $count, $offset);
557
558
		return $this->subsonicResponse(['songsByGenre' =>
559
			['song' => $this->tracksToApi($tracks)]
560
		]);
561
	}
562
563
	/**
564
	 * @SubsonicAPI
565
	 */
566
	protected function getPlaylists() {
567
		$playlists = $this->playlistBusinessLayer->findAll($this->userId);
568
569
		foreach ($playlists as &$playlist) {
570
			$playlist->setDuration($this->playlistBusinessLayer->getDuration($playlist->getId(), $this->userId));
571
		}
572
573
		return $this->subsonicResponse(['playlists' =>
574
			['playlist' => Util::arrayMapMethod($playlists, 'toSubsonicApi')]
575
		]);
576
	}
577
578
	/**
579
	 * @SubsonicAPI
580
	 */
581
	protected function getPlaylist(int $id) {
582
		$playlist = $this->playlistBusinessLayer->find($id, $this->userId);
583
		$tracks = $this->playlistBusinessLayer->getPlaylistTracks($id, $this->userId);
584
		$playlist->setDuration(\array_reduce($tracks, function (?int $accuDuration, Track $track) : int {
585
			return (int)$accuDuration + (int)$track->getLength();
586
		}));
587
588
		$playlistNode = $playlist->toSubsonicApi();
589
		$playlistNode['entry'] = $this->tracksToApi($tracks);
590
591
		return $this->subsonicResponse(['playlist' => $playlistNode]);
592
	}
593
594
	/**
595
	 * @SubsonicAPI
596
	 */
597
	protected function createPlaylist(?string $name, ?string $playlistId, array $songId) {
598
		$songIds = \array_map('self::ripIdPrefix', $songId);
599
600
		// If playlist ID has been passed, then this method actually updates an existing list instead of creating a new one.
601
		// 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).
602
		if (!empty($playlistId)) {
603
			$playlist = $this->playlistBusinessLayer->find((int)$playlistId, $this->userId);
604
		} elseif (!empty($name)) {
605
			$playlist = $this->playlistBusinessLayer->create($name, $this->userId);
606
		} else {
607
			throw new SubsonicException('Playlist ID or name must be specified.', 10);
608
		}
609
610
		$playlist->setTrackIdsFromArray($songIds);
611
		$this->playlistBusinessLayer->update($playlist);
612
613
		return $this->getPlaylist($playlist->getId());
614
	}
615
616
	/**
617
	 * @SubsonicAPI
618
	 */
619
	protected function updatePlaylist(int $playlistId, ?string $name, ?string $comment, array $songIdToAdd, array $songIndexToRemove) {
620
		$songIdsToAdd = \array_map('self::ripIdPrefix', $songIdToAdd);
621
		$songIndicesToRemove = \array_map('intval', $songIndexToRemove);
622
623
		if (!empty($name)) {
624
			$this->playlistBusinessLayer->rename($name, $playlistId, $this->userId);
625
		}
626
627
		if ($comment !== null) {
628
			$this->playlistBusinessLayer->setComment($comment, $playlistId, $this->userId);
629
		}
630
631
		if (!empty($songIndicesToRemove)) {
632
			$this->playlistBusinessLayer->removeTracks($songIndicesToRemove, $playlistId, $this->userId);
633
		}
634
635
		if (!empty($songIdsToAdd)) {
636
			$this->playlistBusinessLayer->addTracks($songIdsToAdd, $playlistId, $this->userId);
637
		}
638
639
		return $this->subsonicResponse([]);
640
	}
641
642
	/**
643
	 * @SubsonicAPI
644
	 */
645
	protected function deletePlaylist(int $id) {
646
		$this->playlistBusinessLayer->delete($id, $this->userId);
647
		return $this->subsonicResponse([]);
648
	}
649
650
	/**
651
	 * @SubsonicAPI
652
	 */
653
	protected function getInternetRadioStations() {
654
		$stations = $this->radioStationBusinessLayer->findAll($this->userId);
655
656
		return $this->subsonicResponse(['internetRadioStations' =>
657
				['internetRadioStation' => \array_map(function($station) {
658
					return [
659
						'id' => $station->getId(),
660
						'name' => $station->getName(),
661
						'streamUrl' => $station->getStreamUrl(),
662
						'homePageUrl' => $station->getHomeUrl()
663
					];
664
				}, $stations)]
665
		]);
666
	}
667
668
	/**
669
	 * @SubsonicAPI
670
	 */
671
	protected function createInternetRadioStation(string $streamUrl, string $name, ?string $homepageUrl) {
672
		$this->radioStationBusinessLayer->create($this->userId, $name, $streamUrl, $homepageUrl);
673
		return $this->subsonicResponse([]);
674
	}
675
676
	/**
677
	 * @SubsonicAPI
678
	 */
679
	protected function updateInternetRadioStation(int $id, string $streamUrl, string $name, ?string $homepageUrl) {
680
		$station = $this->radioStationBusinessLayer->find($id, $this->userId);
681
		$station->setStreamUrl($streamUrl);
682
		$station->setName($name);
683
		$station->setHomeUrl($homepageUrl);
684
		$this->radioStationBusinessLayer->update($station);
685
		return $this->subsonicResponse([]);
686
	}
687
688
	/**
689
	 * @SubsonicAPI
690
	 */
691
	protected function deleteInternetRadioStation(int $id) {
692
		$this->radioStationBusinessLayer->delete($id, $this->userId);
693
		return $this->subsonicResponse([]);
694
	}
695
696
	/**
697
	 * @SubsonicAPI
698
	 */
699
	protected function getUser(string $username) {
700
		if ($username != $this->userId) {
701
			throw new SubsonicException("{$this->userId} is not authorized to get details for other users.", 50);
702
		}
703
704
		return $this->subsonicResponse([
705
			'user' => [
706
				'username' => $username,
707
				'email' => '',
708
				'scrobblingEnabled' => true,
709
				'adminRole' => false,
710
				'settingsRole' => false,
711
				'downloadRole' => true,
712
				'uploadRole' => false,
713
				'playlistRole' => true,
714
				'coverArtRole' => false,
715
				'commentRole' => false,
716
				'podcastRole' => true,
717
				'streamRole' => true,
718
				'jukeboxRole' => false,
719
				'shareRole' => false,
720
				'videoConversionRole' => false,
721
				'folder' => ['artists', 'folders'],
722
			]
723
		]);
724
	}
725
726
	/**
727
	 * @SubsonicAPI
728
	 */
729
	protected function getUsers() {
730
		throw new SubsonicException("{$this->userId} is not authorized to get details for other users.", 50);
731
	}
732
733
	/**
734
	 * @SubsonicAPI
735
	 */
736
	protected function getAvatar(string $username) {
737
		if ($username != $this->userId) {
738
			throw new SubsonicException("{$this->userId} is not authorized to get avatar for other users.", 50);
739
		}
740
741
		$image = $this->userManager->get($username)->getAvatarImage(150);
742
743
		if ($image !== null) {
744
			return new FileResponse(['content' => $image->data(), 'mimetype' => $image->mimeType()]);
745
		} else {
746
			return $this->subsonicErrorResponse(70, 'user has no avatar');
747
		}
748
	}
749
750
	/**
751
	 * @SubsonicAPI
752
	 */
753
	protected function scrobble(array $id, array $time) {
754
		if (\count($id) === 0) {
755
			throw new SubsonicException("Required parameter 'id' missing", 10);
756
		}
757
758
		foreach ($id as $index => $aId) {
759
			list($type, $trackId) = self::parseEntityId($aId);
760
			if ($type === 'track') {
761
				if (isset($time[$index])) {
762
					$timestamp = \substr($time[$index], 0, -3); // cut down from milliseconds to seconds
763
					$timeOfPlay = new \DateTime('@' . $timestamp);
764
				} else {
765
					$timeOfPlay = null;
766
				}
767
				$this->trackBusinessLayer->recordTrackPlayed((int)$trackId, $this->userId, $timeOfPlay);
768
			}
769
		}
770
771
		return $this->subsonicResponse([]);
772
	}
773
774
	/**
775
	 * @SubsonicAPI
776
	 */
777
	protected function star(array $id, array $albumId, array $artistId) {
778
		$targetIds = self::parseStarringParameters($id, $albumId, $artistId);
779
780
		$this->trackBusinessLayer->setStarred($targetIds['tracks'], $this->userId);
781
		$this->albumBusinessLayer->setStarred($targetIds['albums'], $this->userId);
782
		$this->artistBusinessLayer->setStarred($targetIds['artists'], $this->userId);
783
		$this->podcastChannelBusinessLayer->setStarred($targetIds['podcast_channels'], $this->userId);
784
		$this->podcastEpisodeBusinessLayer->setStarred($targetIds['podcast_episodes'], $this->userId);
785
786
		return $this->subsonicResponse([]);
787
	}
788
789
	/**
790
	 * @SubsonicAPI
791
	 */
792
	protected function unstar(array $id, array $albumId, array $artistId) {
793
		$targetIds = self::parseStarringParameters($id, $albumId, $artistId);
794
795
		$this->trackBusinessLayer->unsetStarred($targetIds['tracks'], $this->userId);
796
		$this->albumBusinessLayer->unsetStarred($targetIds['albums'], $this->userId);
797
		$this->artistBusinessLayer->unsetStarred($targetIds['artists'], $this->userId);
798
		$this->podcastChannelBusinessLayer->unsetStarred($targetIds['podcast_channels'], $this->userId);
799
		$this->podcastEpisodeBusinessLayer->unsetStarred($targetIds['podcast_episodes'], $this->userId);
800
801
		return $this->subsonicResponse([]);
802
	}
803
804
	/**
805
	 * @SubsonicAPI
806
	 */
807
	protected function getStarred() {
808
		$starred = $this->doGetStarred();
809
		return $this->searchResponse('starred', $starred, /*$useNewApi=*/false);
810
	}
811
812
	/**
813
	 * @SubsonicAPI
814
	 */
815
	protected function getStarred2() {
816
		$starred = $this->doGetStarred();
817
		return $this->searchResponse('starred2', $starred, /*$useNewApi=*/true);
818
	}
819
820
	/**
821
	 * @SubsonicAPI
822
	 */
823
	protected function getVideos() {
824
		// Feature not supported, return an empty list
825
		return $this->subsonicResponse([
826
			'videos' => [
827
				'video' => []
828
			]
829
		]);
830
	}
831
832
	/**
833
	 * @SubsonicAPI
834
	 */
835
	protected function getPodcasts(?string $id, bool $includeEpisodes = true) {
836
		if ($id !== null) {
837
			$id = self::ripIdPrefix($id);
838
			$channel = $this->podcastService->getChannel($id, $this->userId, $includeEpisodes);
839
			if ($channel === null) {
840
				throw new SubsonicException('Requested channel not found', 70);
841
			}
842
			$channels = [$channel];
843
		} else {
844
			$channels = $this->podcastService->getAllChannels($this->userId, $includeEpisodes);
845
		}
846
847
		return $this->subsonicResponse([
848
			'podcasts' => [
849
				'channel' => Util::arrayMapMethod($channels, 'toSubsonicApi')
850
			]
851
		]);
852
	}
853
854
	/**
855
	 * @SubsonicAPI
856
	 */
857
	protected function getNewestPodcasts(int $count=20) {
858
		$episodes = $this->podcastService->getLatestEpisodes($this->userId, $count);
859
860
		return $this->subsonicResponse([
861
			'newestPodcasts' => [
862
				'episode' => Util::arrayMapMethod($episodes, 'toSubsonicApi')
863
			]
864
		]);
865
	}
866
867
	/**
868
	 * @SubsonicAPI
869
	 */
870
	protected function refreshPodcasts() {
871
		$this->podcastService->updateAllChannels($this->userId);
872
		return $this->subsonicResponse([]);
873
	}
874
875
	/**
876
	 * @SubsonicAPI
877
	 */
878
	protected function createPodcastChannel(string $url) {
879
		$result = $this->podcastService->subscribe($url, $this->userId);
880
881
		switch ($result['status']) {
882
			case PodcastService::STATUS_OK:
883
				return $this->subsonicResponse([]);
884
			case PodcastService::STATUS_INVALID_URL:
885
				throw new SubsonicException("Invalid URL $url", 0);
886
			case PodcastService::STATUS_INVALID_RSS:
887
				throw new SubsonicException("The document at URL $url is not a valid podcast RSS feed", 0);
888
			case PodcastService::STATUS_ALREADY_EXISTS:
889
				throw new SubsonicException('User already has this podcast channel subscribed', 0);
890
			default:
891
				throw new SubsonicException("Unexpected status code {$result['status']}", 0);
892
		}
893
	}
894
895
	/**
896
	 * @SubsonicAPI
897
	 */
898
	protected function deletePodcastChannel(string $id) {
899
		$id = self::ripIdPrefix($id);
900
		$status = $this->podcastService->unsubscribe($id, $this->userId);
901
902
		switch ($status) {
903
			case PodcastService::STATUS_OK:
904
				return $this->subsonicResponse([]);
905
			case PodcastService::STATUS_NOT_FOUND:
906
				throw new SubsonicException('Channel to be deleted not found', 70);
907
			default:
908
				throw new SubsonicException("Unexpected status code $status", 0);
909
		}
910
	}
911
912
	/**
913
	 * @SubsonicAPI
914
	 */
915
	protected function getBookmarks() {
916
		$bookmarkNodes = [];
917
		$bookmarks = $this->bookmarkBusinessLayer->findAll($this->userId);
918
919
		foreach ($bookmarks as $bookmark) {
920
			$node = $bookmark->toSubsonicApi();
921
			$entryId = $bookmark->getEntryId();
922
			$type = $bookmark->getType();
923
924
			try {
925
				if ($type === Bookmark::TYPE_TRACK) {
926
					$track = $this->trackBusinessLayer->find($entryId, $this->userId);
927
					$track->setAlbum($this->albumBusinessLayer->find($track->getAlbumId(), $this->userId));
928
					$node['entry'] = $track->toSubsonicApi($this->l10n);
929
				} elseif ($type === Bookmark::TYPE_PODCAST_EPISODE) {
930
					$node['entry'] = $this->podcastEpisodeBusinessLayer->find($entryId, $this->userId)->toSubsonicApi();
931
				} else {
932
					$this->logger->log("Bookmark {$bookmark->getId()} had unexpected entry type $type", 'warn');
933
				}
934
				$bookmarkNodes[] = $node;
935
			} catch (BusinessLayerException $e) {
936
				$this->logger->log("Bookmarked entry with type $type and id $entryId not found", 'warn');
937
			}
938
		}
939
940
		return $this->subsonicResponse(['bookmarks' => ['bookmark' => $bookmarkNodes]]);
941
	}
942
943
	/**
944
	 * @SubsonicAPI
945
	 */
946
	protected function createBookmark(string $id, int $position, ?string $comment) {
947
		list($type, $entityId) = self::parseBookamrkIdParam($id);
948
		$this->bookmarkBusinessLayer->addOrUpdate($this->userId, $type, $entityId, $position, $comment);
949
		return $this->subsonicResponse([]);
950
	}
951
952
	/**
953
	 * @SubsonicAPI
954
	 */
955
	protected function deleteBookmark(string $id) {
956
		list($type, $entityId) = self::parseBookamrkIdParam($id);
957
958
		$bookmark = $this->bookmarkBusinessLayer->findByEntry($type, $entityId, $this->userId);
959
		$this->bookmarkBusinessLayer->delete($bookmark->getId(), $this->userId);
960
961
		return $this->subsonicResponse([]);
962
	}
963
964
	/**
965
	 * @SubsonicAPI
966
	 */
967
	protected function getPlayQueue() {
968
		// TODO: not supported yet
969
		return $this->subsonicResponse(['playQueue' => []]);
970
	}
971
972
	/**
973
	 * @SubsonicAPI
974
	 */
975
	protected function savePlayQueue() {
976
		// TODO: not supported yet
977
		return $this->subsonicResponse([]);
978
	}
979
980
	/**
981
	 * @SubsonicAPI
982
	 */
983
	protected function getScanStatus() {
984
		return $this->subsonicResponse(['scanStatus' => [
985
				'scanning' => false,
986
				'count' => $this->trackBusinessLayer->count($this->userId)
987
		]]);
988
	}
989
990
	/**
991
	 * @SubsonicAPI
992
	 */
993
	protected function getNowPlaying() {
994
		// TODO: not supported yet
995
		return $this->subsonicResponse(['nowPlaying' => ['entry' => []]]);
996
	}
997
998
	/* -------------------------------------------------------------------------
999
	 * Helper methods
1000
	 *------------------------------------------------------------------------*/
1001
1002
	private static function ensureParamHasValue(string $paramName, /*mixed*/ $paramValue) {
1003
		if ($paramValue === null || $paramValue === '') {
1004
			throw new SubsonicException("Required parameter '$paramName' missing", 10);
1005
		}
1006
	}
1007
1008
	private static function parseBookamrkIdParam(string $id) : array {
1009
		list($typeName, $entityId) = self::parseEntityId($id);
1010
1011
		if ($typeName === 'track') {
1012
			$type = Bookmark::TYPE_TRACK;
1013
		} elseif ($typeName === 'podcast_episode') {
1014
			$type = Bookmark::TYPE_PODCAST_EPISODE;
1015
		} else {
1016
			throw new SubsonicException("Unsupported ID format $id", 0);
1017
		}
1018
1019
		return [$type, $entityId];
1020
	}
1021
1022
	/**
1023
	 * Parse parameters used in the `star` and `unstar` API methods
1024
	 */
1025
	private static function parseStarringParameters(array $ids, array $albumIds, array $artistIds) {
1026
		// album IDs from newer clients
1027
		$albumIds = \array_map('self::ripIdPrefix', $albumIds);
1028
1029
		// artist IDs from newer clients
1030
		$artistIds = \array_map('self::ripIdPrefix', $artistIds);
1031
1032
		// Song IDs from newer clients and song/folder/album/artist IDs from older clients are all packed in $ids.
1033
		// Also podcast IDs may come there; that is not documented part of the API but at least DSub does that.
1034
1035
		$trackIds = [];
1036
		$channelIds = [];
1037
		$episodeIds = [];
1038
1039
		foreach ($ids as $prefixedId) {
1040
			list($type, $id) = self::parseEntityId($prefixedId);
1041
1042
			if ($type == 'track') {
1043
				$trackIds[] = $id;
1044
			} elseif ($type == 'album') {
1045
				$albumIds[] = $id;
1046
			} elseif ($type == 'artist') {
1047
				$artistIds[] = $id;
1048
			} elseif ($type == 'podcast_channel') {
1049
				$channelIds[] = $id;
1050
			} elseif ($type == 'podcast_episode') {
1051
				$episodeIds[] = $id;
1052
			} elseif ($type == 'folder') {
1053
				throw new SubsonicException('Starring folders is not supported', 0);
1054
			} else {
1055
				throw new SubsonicException("Unexpected ID format: $prefixedId", 0);
1056
			}
1057
		}
1058
1059
		return [
1060
			'tracks' => $trackIds,
1061
			'albums' => $albumIds,
1062
			'artists' => $artistIds,
1063
			'podcast_channels' => $channelIds,
1064
			'podcast_episodes' => $episodeIds
1065
		];
1066
	}
1067
1068
	private function getFilesystemNode($id) {
1069
		$rootFolder = $this->librarySettings->getFolder($this->userId);
1070
		$nodes = $rootFolder->getById($id);
1071
1072
		if (\count($nodes) != 1) {
1073
			throw new SubsonicException('file not found', 70);
1074
		}
1075
1076
		return $nodes[0];
1077
	}
1078
1079
	private static function nameWithoutArticle(?string $name, array $ignoredArticles) : ?string {
1080
		foreach ($ignoredArticles as $article) {
1081
			if (!empty($name) && Util::startsWith($name, $article . ' ', /*ignore_case=*/true)) {
1082
				return \substr($name, \strlen($article) + 1);
1083
			}
1084
		}
1085
		return $name;
1086
	}
1087
1088
	private static function getIndexingChar(?string $name) {
1089
		// For unknown artists, use '?'
1090
		$char = '?';
1091
1092
		if (!empty($name)) {
1093
			$char = \mb_convert_case(\mb_substr($name, 0, 1), MB_CASE_UPPER);
1094
		}
1095
		// Bundle all numeric characters together
1096
		if (\is_numeric($char)) {
1097
			$char = '#';
1098
		}
1099
1100
		return $char;
1101
	}
1102
1103
	private function getSubfoldersAndTracks(Folder $folder) : array {
1104
		$nodes = $folder->getDirectoryListing();
1105
		$subFolders = \array_filter($nodes, function ($n) {
1106
			return ($n instanceof Folder) && $this->librarySettings->pathBelongsToMusicLibrary($n->getPath(), $this->userId);
1107
		});
1108
1109
		$tracks = $this->trackBusinessLayer->findAllByFolder($folder->getId(), $this->userId);
1110
1111
		return [$subFolders, $tracks];
1112
	}
1113
1114
	private function getIndexesForFolders() {
1115
		$ignoredArticles = $this->librarySettings->getIgnoredArticles($this->userId);
1116
		$rootFolder = $this->librarySettings->getFolder($this->userId);
1117
1118
		list($subFolders, $tracks) = $this->getSubfoldersAndTracks($rootFolder);
1119
1120
		$indexes = [];
1121
		foreach ($subFolders as $folder) {
1122
			$sortName = self::nameWithoutArticle($folder->getName(), $ignoredArticles);
1123
			$indexes[self::getIndexingChar($sortName)][] = [
1124
				'sortName' => $sortName,
1125
				'artist' => [
1126
					'name' => $folder->getName(),
1127
					'id' => 'folder-' . $folder->getId()
1128
				]
1129
			];
1130
		}
1131
		\ksort($indexes, SORT_LOCALE_STRING);
1132
1133
		$folders = [];
1134
		foreach ($indexes as $indexChar => $bucketArtists) {
1135
			Util::arraySortByColumn($bucketArtists, 'sortName');
1136
			$folders[] = ['name' => $indexChar, 'artist' => \array_column($bucketArtists, 'artist')];
1137
		}
1138
1139
		return $this->subsonicResponse(['indexes' => [
1140
			'ignoredArticles' => \implode(' ', $ignoredArticles),
1141
			'index' => $folders,
1142
			'child' => $this->tracksToApi($tracks)
1143
		]]);
1144
	}
1145
1146
	private function getMusicDirectoryForFolder($id) {
1147
		$folderId = self::ripIdPrefix($id); // get rid of 'folder-' prefix
1148
		$folder = $this->getFilesystemNode($folderId);
1149
1150
		if (!($folder instanceof Folder)) {
1151
			throw new SubsonicException("$id is not a valid folder", 70);
1152
		}
1153
1154
		list($subFolders, $tracks) = $this->getSubfoldersAndTracks($folder);
1155
1156
		$children = \array_merge(
1157
			\array_map([$this, 'folderToApi'], $subFolders),
1158
			$this->tracksToApi($tracks)
1159
		);
1160
1161
		$content = [
1162
			'directory' => [
1163
				'id' => $id,
1164
				'name' => $folder->getName(),
1165
				'child' => $children
1166
			]
1167
		];
1168
1169
		// Parent folder ID is included if and only if the parent folder is not the top level
1170
		$rootFolderId = $this->librarySettings->getFolder($this->userId)->getId();
1171
		$parentFolderId = $folder->getParent()->getId();
1172
		if ($rootFolderId != $parentFolderId) {
1173
			$content['parent'] = 'folder-' . $parentFolderId;
1174
		}
1175
1176
		return $this->subsonicResponse($content);
1177
	}
1178
1179
	private function getIndexesForArtists($rootElementName = 'indexes') {
1180
		$ignoredArticles = $this->librarySettings->getIgnoredArticles($this->userId);
1181
		$artists = $this->artistBusinessLayer->findAllHavingAlbums($this->userId, SortBy::Name);
1182
1183
		$indexes = [];
1184
		foreach ($artists as $artist) {
1185
			$sortName = self::nameWithoutArticle($artist->getName(), $ignoredArticles);
1186
			$indexes[self::getIndexingChar($sortName)][] = ['sortName' => $sortName, 'artist' => $this->artistToApi($artist)];
1187
		}
1188
		\ksort($indexes, SORT_LOCALE_STRING);
1189
1190
		$result = [];
1191
		foreach ($indexes as $indexChar => $bucketArtists) {
1192
			Util::arraySortByColumn($bucketArtists, 'sortName');
1193
			$result[] = ['name' => $indexChar, 'artist' => \array_column($bucketArtists, 'artist')];
1194
		}
1195
1196
		return $this->subsonicResponse([$rootElementName => [
1197
			'ignoredArticles' => \implode(' ', $ignoredArticles),
1198
			'index' => $result
1199
		]]);
1200
	}
1201
1202
	private function getMusicDirectoryForArtist($id) {
1203
		$artistId = self::ripIdPrefix($id); // get rid of 'artist-' prefix
1204
1205
		$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
1206
		$albums = $this->albumBusinessLayer->findAllByArtist($artistId, $this->userId);
1207
1208
		return $this->subsonicResponse([
1209
			'directory' => [
1210
				'id' => $id,
1211
				'name' => $artist->getNameString($this->l10n),
1212
				'child' => \array_map([$this, 'albumToOldApi'], $albums)
1213
			]
1214
		]);
1215
	}
1216
1217
	private function getMusicDirectoryForAlbum($id) {
1218
		$albumId = self::ripIdPrefix($id); // get rid of 'album-' prefix
1219
1220
		$album = $this->albumBusinessLayer->find($albumId, $this->userId);
1221
		$albumName = $album->getNameString($this->l10n);
1222
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $this->userId);
1223
1224
		return $this->subsonicResponse([
1225
			'directory' => [
1226
				'id' => $id,
1227
				'parent' => 'artist-' . $album->getAlbumArtistId(),
1228
				'name' => $albumName,
1229
				'child' => \array_map(function ($track) use ($album) {
1230
					$track->setAlbum($album);
1231
					return $track->toSubsonicApi($this->l10n);
1232
				}, $tracks)
1233
			]
1234
		]);
1235
	}
1236
1237
	private function getMusicDirectoryForPodcastChannel($id) {
1238
		$channelId = self::ripIdPrefix($id); // get rid of 'podcast_channel-' prefix
1239
		$channel = $this->podcastService->getChannel($channelId, $this->userId, /*$includeEpisodes=*/ true);
1240
1241
		if ($channel === null) {
1242
			throw new SubsonicException("Podcast channel $channelId not found", 0);
1243
		}
1244
1245
		return $this->subsonicResponse([
1246
			'directory' => [
1247
				'id' => $id,
1248
				'name' => $channel->getTitle(),
1249
				'child' => Util::arrayMapMethod($channel->getEpisodes(), 'toSubsonicApi')
1250
			]
1251
		]);
1252
	}
1253
1254
	/**
1255
	 * @param Folder $folder
1256
	 * @return array
1257
	 */
1258
	private function folderToApi($folder) {
1259
		return [
1260
			'id' => 'folder-' . $folder->getId(),
1261
			'title' => $folder->getName(),
1262
			'isDir' => true
1263
		];
1264
	}
1265
1266
	/**
1267
	 * @param Artist $artist
1268
	 * @return array
1269
	 */
1270
	private function artistToApi($artist) {
1271
		$id = $artist->getId();
1272
		$result = [
1273
			'name' => $artist->getNameString($this->l10n),
1274
			'id' => $id ? ('artist-' . $id) : '-1', // getArtistInfo may show artists without ID
1275
			'albumCount' => $id ? $this->albumBusinessLayer->countByArtist($id) : 0,
1276
			'starred' => Util::formatZuluDateTime($artist->getStarred())
1277
		];
1278
1279
		if (!empty($artist->getCoverFileId())) {
1280
			$result['coverArt'] = $result['id'];
1281
			$result['artistImageUrl'] = $this->artistImageUrl($result['id']);
1282
		}
1283
1284
		return $result;
1285
	}
1286
1287
	/**
1288
	 * The "old API" format is used e.g. in getMusicDirectory and getAlbumList
1289
	 */
1290
	private function albumToOldApi(Album $album) : array {
1291
		$result = $this->albumCommonApiFields($album);
1292
1293
		$result['parent'] = 'artist-' . $album->getAlbumArtistId();
1294
		$result['title'] = $album->getNameString($this->l10n);
1295
		$result['isDir'] = true;
1296
1297
		return $result;
1298
	}
1299
1300
	/**
1301
	 * The "new API" format is used e.g. in getAlbum and getAlbumList2
1302
	 */
1303
	private function albumToNewApi(Album $album) : array {
1304
		$result = $this->albumCommonApiFields($album);
1305
1306
		$result['artistId'] = 'artist-' . $album->getAlbumArtistId();
1307
		$result['name'] = $album->getNameString($this->l10n);
1308
		$result['songCount'] = $this->trackBusinessLayer->countByAlbum($album->getId());
1309
		$result['duration'] = $this->trackBusinessLayer->totalDurationOfAlbum($album->getId());
1310
1311
		return $result;
1312
	}
1313
1314
	private function albumCommonApiFields(Album $album) : array {
1315
		$genreString = \implode(', ', \array_map(function (Genre $genre) {
1316
			return $genre->getNameString($this->l10n);
1317
		}, $album->getGenres() ?? []));
1318
1319
		return [
1320
			'id' => 'album-' . $album->getId(),
1321
			'artist' => $album->getAlbumArtistNameString($this->l10n),
1322
			'created' => Util::formatZuluDateTime($album->getCreated()),
1323
			'coverArt' => empty($album->getCoverFileId()) ? null : 'album-' . $album->getId(),
1324
			'starred' => Util::formatZuluDateTime($album->getStarred()),
1325
			'year' => $album->yearToAPI(),
1326
			'genre' => $genreString ?: null
1327
		];
1328
	}
1329
1330
	/**
1331
	 * @param Track[] $tracks
1332
	 * @return array
1333
	 */
1334
	private function tracksToApi(array $tracks) : array {
1335
		$this->albumBusinessLayer->injectAlbumsToTracks($tracks, $this->userId);
1336
		return Util::arrayMapMethod($tracks, 'toSubsonicApi', [$this->l10n]);
1337
	}
1338
1339
	/**
1340
	 * Common logic for getAlbumList and getAlbumList2
1341
	 * @return Album[]
1342
	 */
1343
	private function albumsForGetAlbumList(
1344
			string $type, ?string $genre, ?int $fromYear, ?int $toYear, int $size, int $offset) : array {
1345
		$size = \min($size, 500); // the API spec limits the maximum amount to 500
1346
1347
		$albums = [];
1348
1349
		switch ($type) {
1350
			case 'random':
1351
				$allAlbums = $this->albumBusinessLayer->findAll($this->userId);
1352
				$indices = $this->random->getIndices(\count($allAlbums), $offset, $size, $this->userId, 'subsonic_albums');
1353
				$albums = Util::arrayMultiGet($allAlbums, $indices);
1354
				break;
1355
			case 'starred':
1356
				$albums = $this->albumBusinessLayer->findAllStarred($this->userId, $size, $offset);
1357
				break;
1358
			case 'alphabeticalByName':
1359
				$albums = $this->albumBusinessLayer->findAll($this->userId, SortBy::Name, $size, $offset);
1360
				break;
1361
			case 'alphabeticalByArtist':
1362
				$albums = $this->albumBusinessLayer->findAll($this->userId, SortBy::Parent, $size, $offset);
1363
				break;
1364
			case 'byGenre':
1365
				self::ensureParamHasValue('genre', $genre);
1366
				$albums = $this->findAlbumsByGenre($genre, $size, $offset);
1367
				break;
1368
			case 'byYear':
1369
				self::ensureParamHasValue('fromYear', $fromYear);
1370
				self::ensureParamHasValue('toYear', $toYear);
1371
				$albums = $this->albumBusinessLayer->findAllByYearRange($fromYear, $toYear, $this->userId, $size, $offset);
1372
				break;
1373
			case 'newest':
1374
				$albums = $this->albumBusinessLayer->findAll($this->userId, SortBy::Newest, $size, $offset);
1375
				break;
1376
			case 'frequent':
1377
				$albums = $this->albumBusinessLayer->findFrequentPlay($this->userId, $size, $offset);
1378
				break;
1379
			case 'recent':
1380
				$albums = $this->albumBusinessLayer->findRecentPlay($this->userId, $size, $offset);
1381
				break;
1382
			case 'highest':
1383
				// TODO
1384
			default:
1385
				$this->logger->log("Album list type '$type' is not supported", 'debug');
1386
				break;
1387
		}
1388
1389
		return $albums;
1390
	}
1391
1392
	/**
1393
	 * Given any entity ID like 'track-123' or 'album-2' or 'artist-3' or 'folder-4', return the matching
1394
	 * numeric artist identifier if possible (may be e.g. performer of the track or album, or an artist
1395
	 * with a name matching the folder name)
1396
	 */
1397
	private function getArtistIdFromEntityId(string $entityId) : ?int {
1398
		list($type, $id) = self::parseEntityId($entityId);
1399
1400
		switch ($type) {
1401
			case 'artist':
1402
				return $id;
1403
			case 'album':
1404
				return $this->albumBusinessLayer->find($id, $this->userId)->getAlbumArtistId();
1405
			case 'track':
1406
				return $this->trackBusinessLayer->find($id, $this->userId)->getArtistId();
1407
			case 'folder':
1408
				$folder = $this->librarySettings->getFolder($this->userId)->getById($id)[0] ?? null;
1409
				if ($folder !== null) {
1410
					$artist = $this->artistBusinessLayer->findAllByName($folder->getName(), $this->userId)[0] ?? null;
1411
					if ($artist !== null) {
1412
						return $artist->getId();
1413
					}
1414
				}
1415
				break;
1416
		}
1417
1418
		return null;
1419
	}
1420
1421
	/**
1422
	 * Common logic for getArtistInfo and getArtistInfo2
1423
	 */
1424
	private function doGetArtistInfo(string $rootName, string $id, bool $includeNotPresent) {
1425
		$content = [];
1426
1427
		$artistId = $this->getArtistIdFromEntityId($id);
1428
		if ($artistId !== null) {
1429
			$info = $this->lastfmService->getArtistInfo($artistId, $this->userId);
1430
1431
			if (isset($info['artist'])) {
1432
				$content = [
1433
					'biography' => $info['artist']['bio']['summary'],
1434
					'lastFmUrl' => $info['artist']['url'],
1435
					'musicBrainzId' => $info['artist']['mbid'] ?? null
1436
				];
1437
1438
				$similarArtists = $this->lastfmService->getSimilarArtists($artistId, $this->userId, $includeNotPresent);
1439
				$content['similarArtist'] = \array_map([$this, 'artistToApi'], $similarArtists);
1440
			}
1441
1442
			$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
1443
			if ($artist->getCoverFileId() !== null) {
1444
				$content['largeImageUrl'] = [$this->artistImageUrl('artist-' . $artistId)];
1445
			}
1446
		}
1447
1448
		// This method is unusual in how it uses non-attribute elements in the response. On the other hand,
1449
		// all the details of the <similarArtist> elements are rendered as attributes. List those separately.
1450
		$attributeKeys = ['name', 'id', 'albumCount', 'coverArt', 'artistImageUrl', 'starred'];
1451
1452
		return $this->subsonicResponse([$rootName => $content], $attributeKeys);
1453
	}
1454
1455
	/**
1456
	 * Given any entity ID like 'track-123' or 'album-2' or 'folder-4', return the matching numeric
1457
	 * album identifier if possible (may be e.g. host album of the track or album with a name
1458
	 * matching the folder name)
1459
	 */
1460
	private function getAlbumIdFromEntityId(string $entityId) : ?int {
1461
		list($type, $id) = self::parseEntityId($entityId);
1462
1463
		switch ($type) {
1464
			case 'album':
1465
				return $id;
1466
			case 'track':
1467
				return $this->trackBusinessLayer->find($id, $this->userId)->getAlbumId();
1468
			case 'folder':
1469
				$folder = $this->librarySettings->getFolder($this->userId)->getById($id)[0] ?? null;
1470
				if ($folder !== null) {
1471
					$album = $this->albumBusinessLayer->findAllByName($folder->getName(), $this->userId)[0] ?? null;
1472
					if ($album !== null) {
1473
						return $album->getId();
1474
					}
1475
				}
1476
				break;
1477
		}
1478
1479
		return null;
1480
	}
1481
	/**
1482
	 * Common logic for getAlbumInfo and getAlbumInfo2
1483
	 */
1484
	private function doGetAlbumInfo(string $rootName, string $id) {
1485
		$content = [];
1486
1487
		$albumId = $this->getAlbumIdFromEntityId($id);
1488
		if ($albumId !== null) {
1489
			$info = $this->lastfmService->getAlbumInfo($albumId, $this->userId);
1490
1491
			if (isset($info['album'])) {
1492
				$content = [
1493
					'notes' => $info['album']['wiki']['summary'] ?? null,
1494
					'lastFmUrl' => $info['album']['url'],
1495
					'musicBrainzId' => $info['album']['mbid'] ?? null
1496
				];
1497
1498
				foreach ($info['album']['image'] ?? [] as $imageInfo) {
1499
					if (!empty($imageInfo['size'])) {
1500
						$content[$imageInfo['size'] . 'ImageUrl'] = $imageInfo['#text'];
1501
					}
1502
				}
1503
			}
1504
		}
1505
1506
		// This method is unusual in how it uses non-attribute elements in the response.
1507
		return $this->subsonicResponse([$rootName => $content], []);
1508
	}
1509
1510
	/**
1511
	 * Common logic for getSimilarSongs and getSimilarSongs2
1512
	 */
1513
	private function doGetSimilarSongs(string $rootName, string $id, int $count) {
1514
		if (Util::startsWith($id, 'artist')) {
1515
			$artistId = self::ripIdPrefix($id);
1516
		} elseif (Util::startsWith($id, 'album')) {
1517
			$albumId = self::ripIdPrefix($id);
1518
			$artistId = $this->albumBusinessLayer->find($albumId, $this->userId)->getAlbumArtistId();
1519
		} elseif (Util::startsWith($id, 'track')) {
1520
			$trackId = self::ripIdPrefix($id);
1521
			$artistId = $this->trackBusinessLayer->find($trackId, $this->userId)->getArtistId();
1522
		} else {
1523
			throw new SubsonicException("Id $id has a type not supported on getSimilarSongs", 0);
1524
		}
1525
1526
		$artists = $this->lastfmService->getSimilarArtists($artistId, $this->userId);
1527
		$artists[] = $this->artistBusinessLayer->find($artistId, $this->userId);
1528
1529
		// Get all songs by the found artists
1530
		$songs = [];
1531
		foreach ($artists as $artist) {
1532
			$songs = \array_merge($songs, $this->trackBusinessLayer->findAllByArtist($artist->getId(), $this->userId));
1533
		}
1534
1535
		// Randomly select the desired number of songs
1536
		$songs = $this->random->pickItems($songs, $count);
1537
1538
		return $this->subsonicResponse([$rootName => [
1539
			'song' => $this->tracksToApi($songs)
1540
		]]);
1541
	}
1542
1543
	/**
1544
	 * Common logic for search2 and search3
1545
	 * @return array with keys 'artists', 'albums', and 'tracks'
1546
	 */
1547
	private function doSearch(string $query, int $artistCount, int $artistOffset,
1548
			int $albumCount, int $albumOffset, int $songCount, int $songOffset) : array {
1549
1550
		if (empty($query)) {
1551
			throw new SubsonicException("The 'query' argument is mandatory", 10);
1552
		}
1553
1554
		return [
1555
			'artists' => $this->artistBusinessLayer->findAllByName($query, $this->userId, MatchMode::Substring, $artistCount, $artistOffset),
1556
			'albums' => $this->albumBusinessLayer->findAllByNameRecursive($query, $this->userId, $albumCount, $albumOffset),
1557
			'tracks' => $this->trackBusinessLayer->findAllByNameRecursive($query, $this->userId, $songCount, $songOffset)
1558
		];
1559
	}
1560
1561
	/**
1562
	 * Common logic for getStarred and getStarred2
1563
	 * @return array
1564
	 */
1565
	private function doGetStarred() {
1566
		return [
1567
			'artists' => $this->artistBusinessLayer->findAllStarred($this->userId),
1568
			'albums' => $this->albumBusinessLayer->findAllStarred($this->userId),
1569
			'tracks' => $this->trackBusinessLayer->findAllStarred($this->userId)
1570
		];
1571
	}
1572
1573
	/**
1574
	 * Common response building logic for search2, search3, getStarred, and getStarred2
1575
	 * @param string $title Name of the main node in the response message
1576
	 * @param array $results Search results with keys 'artists', 'albums', and 'tracks'
1577
	 * @param boolean $useNewApi Set to true for search3 and getStarred2. There is a difference
1578
	 *                           in the formatting of the album nodes.
1579
	 * @return \OCP\AppFramework\Http\Response
1580
	 */
1581
	private function searchResponse($title, $results, $useNewApi) {
1582
		$albumMapFunc = $useNewApi ? 'albumToNewApi' : 'albumToOldApi';
1583
1584
		return $this->subsonicResponse([$title => [
1585
			'artist' => \array_map([$this, 'artistToApi'], $results['artists']),
1586
			'album' => \array_map([$this, $albumMapFunc], $results['albums']),
1587
			'song' => $this->tracksToApi($results['tracks'])
1588
		]]);
1589
	}
1590
1591
	/**
1592
	 * Find tracks by genre name
1593
	 * @param string $genreName
1594
	 * @param int|null $limit
1595
	 * @param int|null $offset
1596
	 * @return Track[]
1597
	 */
1598
	private function findTracksByGenre($genreName, $limit=null, $offset=null) {
1599
		$genre = $this->findGenreByName($genreName);
1600
1601
		if ($genre) {
1602
			return $this->trackBusinessLayer->findAllByGenre($genre->getId(), $this->userId, $limit, $offset);
1603
		} else {
1604
			return [];
1605
		}
1606
	}
1607
1608
	/**
1609
	 * Find albums by genre name
1610
	 * @param string $genreName
1611
	 * @param int|null $limit
1612
	 * @param int|null $offset
1613
	 * @return Album[]
1614
	 */
1615
	private function findAlbumsByGenre($genreName, $limit=null, $offset=null) {
1616
		$genre = $this->findGenreByName($genreName);
1617
1618
		if ($genre) {
1619
			return $this->albumBusinessLayer->findAllByGenre($genre->getId(), $this->userId, $limit, $offset);
1620
		} else {
1621
			return [];
1622
		}
1623
	}
1624
1625
	private function findGenreByName($name) {
1626
		$genreArr = $this->genreBusinessLayer->findAllByName($name, $this->userId);
1627
		if (\count($genreArr) == 0 && $name == Genre::unknownNameString($this->l10n)) {
1628
			$genreArr = $this->genreBusinessLayer->findAllByName('', $this->userId);
1629
		}
1630
		return \count($genreArr) ? $genreArr[0] : null;
1631
	}
1632
1633
	private function artistImageUrl(string $id) : string {
1634
		$par = $this->request->getParams();
1635
		return $this->urlGenerator->linkToRouteAbsolute('music.subsonic.handleRequest', ['method' => 'getCoverArt'])
1636
			. "?u={$par['u']}&p={$par['p']}&v={$par['v']}&c={$par['c']}&id=$id&size=" . CoverHelper::DO_NOT_CROP_OR_SCALE;
1637
		// Note: Using DO_NOT_CROP_OR_SCALE (-1) as size is our proprietary extension and not part of the Subsonic API
1638
	}
1639
1640
	/**
1641
	 * Given a prefixed ID like 'artist-123' or 'track-45', return the string part and the numeric part.
1642
	 * @throws SubsonicException if the \a $id doesn't follow the expected pattern
1643
	 */
1644
	private static function parseEntityId(string $id) : array {
1645
		$parts = \explode('-', $id);
1646
		if (\count($parts) !== 2) {
1647
			throw new SubsonicException("Unexpected ID format: $id", 0);
1648
		}
1649
		$parts[1] = (int)$parts[1];
1650
		return $parts;
1651
	}
1652
1653
	/**
1654
	 * Given a prefixed ID like 'artist-123' or 'track-45', return just the numeric part.
1655
	 */
1656
	private static function ripIdPrefix(string $id) : int {
1657
		return self::parseEntityId($id)[1];
1658
	}
1659
1660
	private function subsonicResponse($content, $useAttributes=true, $status = 'ok') {
1661
		$content['status'] = $status;
1662
		$content['version'] = self::API_VERSION;
1663
		$responseData = ['subsonic-response' => Util::arrayRejectRecursive($content, 'is_null')];
1664
1665
		if ($this->format == 'json') {
1666
			$response = new JSONResponse($responseData);
1667
		} elseif ($this->format == 'jsonp') {
1668
			$responseData = \json_encode($responseData);
1669
			$response = new DataDisplayResponse("{$this->callback}($responseData);");
1670
			$response->addHeader('Content-Type', 'text/javascript; charset=UTF-8');
1671
		} else {
1672
			if (\is_array($useAttributes)) {
1673
				$useAttributes = \array_merge($useAttributes, ['status', 'version', 'xmlns']);
1674
			}
1675
			$responseData['subsonic-response']['xmlns'] = 'http://subsonic.org/restapi';
1676
			$response = new XmlResponse($responseData, $useAttributes);
1677
		}
1678
1679
		return $response;
1680
	}
1681
1682
	public function subsonicErrorResponse($errorCode, $errorMessage) {
1683
		return $this->subsonicResponse([
1684
				'error' => [
1685
					'code' => $errorCode,
1686
					'message' => $errorMessage
1687
				]
1688
			], true, 'failed');
1689
	}
1690
}
1691