Passed
Pull Request — master (#1141)
by Pauli
04:07
created

SubsonicController::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 42
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 20
c 0
b 0
f 0
nc 1
nop 21
dl 0
loc 42
rs 9.6

How to fix   Many Parameters   

Many Parameters

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

There are several approaches to avoid long parameter lists:

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