Passed
Push — master ( f9c9de...599a1b )
by Pauli
01:59
created

SubsonicController::deleteInternetRadioStation()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Importance

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