Passed
Push — master ( 3e7f37...47396a )
by Pauli
02:30
created

SubsonicController::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 44
Code Lines 21

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 1
eloc 21
nc 1
nop 22
dl 0
loc 44
rs 9.584
c 2
b 0
f 0

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 - 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.1';
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' => true,
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 scrobble(array $id, array $time) {
747
		if (\count($id) === 0) {
748
			throw new SubsonicException("Required parameter 'id' missing", 10);
749
		}
750
751
		foreach ($id as $index => $aId) {
752
			list($type, $trackId) = \explode('-', $aId);
753
			if ($type === 'track') {
754
				$timestamp = $time[$index] ?? null;
755
				$timeOfPlay = ($timestamp === null) ? null : new \DateTime('@' . $timestamp);
756
				$this->trackBusinessLayer->recordTrackPlayed((int)$trackId, $this->userId, $timeOfPlay);
757
			}
758
		}
759
760
		return $this->subsonicResponse([]);
761
	}
762
763
	/**
764
	 * @SubsonicAPI
765
	 */
766
	private function star(array $id, array $albumId, array $artistId) {
767
		$targetIds = self::parseStarringParameters($id, $albumId, $artistId);
768
769
		$this->trackBusinessLayer->setStarred($targetIds['tracks'], $this->userId);
770
		$this->albumBusinessLayer->setStarred($targetIds['albums'], $this->userId);
771
		$this->artistBusinessLayer->setStarred($targetIds['artists'], $this->userId);
772
		$this->podcastChannelBusinessLayer->setStarred($targetIds['podcast_channels'], $this->userId);
773
		$this->podcastEpisodeBusinessLayer->setStarred($targetIds['podcast_episodes'], $this->userId);
774
775
		return $this->subsonicResponse([]);
776
	}
777
778
	/**
779
	 * @SubsonicAPI
780
	 */
781
	private function unstar(array $id, array $albumId, array $artistId) {
782
		$targetIds = self::parseStarringParameters($id, $albumId, $artistId);
783
784
		$this->trackBusinessLayer->unsetStarred($targetIds['tracks'], $this->userId);
785
		$this->albumBusinessLayer->unsetStarred($targetIds['albums'], $this->userId);
786
		$this->artistBusinessLayer->unsetStarred($targetIds['artists'], $this->userId);
787
		$this->podcastChannelBusinessLayer->unsetStarred($targetIds['podcast_channels'], $this->userId);
788
		$this->podcastEpisodeBusinessLayer->unsetStarred($targetIds['podcast_episodes'], $this->userId);
789
790
		return $this->subsonicResponse([]);
791
	}
792
793
	/**
794
	 * @SubsonicAPI
795
	 */
796
	private function getStarred() {
797
		$starred = $this->doGetStarred();
798
		return $this->searchResponse('starred', $starred, /*$useNewApi=*/false);
799
	}
800
801
	/**
802
	 * @SubsonicAPI
803
	 */
804
	private function getStarred2() {
805
		$starred = $this->doGetStarred();
806
		return $this->searchResponse('starred2', $starred, /*$useNewApi=*/true);
807
	}
808
809
	/**
810
	 * @SubsonicAPI
811
	 */
812
	private function getVideos() {
813
		// Feature not supported, return an empty list
814
		return $this->subsonicResponse([
815
			'videos' => [
816
				'video' => []
817
			]
818
		]);
819
	}
820
821
	/**
822
	 * @SubsonicAPI
823
	 */
824
	private function getPodcasts(?string $id, bool $includeEpisodes = true) {
825
		if ($id !== null) {
826
			$id = self::ripIdPrefix($id);
827
			$channel = $this->podcastService->getChannel($id, $this->userId, $includeEpisodes);
828
			if ($channel === null) {
829
				throw new SubsonicException('Requested channel not found', 70);
830
			}
831
			$channels = [$channel];
832
		} else {
833
			$channels = $this->podcastService->getAllChannels($this->userId, $includeEpisodes);
834
		}
835
836
		return $this->subsonicResponse([
837
			'podcasts' => [
838
				'channel' => Util::arrayMapMethod($channels, 'toSubsonicApi')
839
			]
840
		]);
841
	}
842
843
	/**
844
	 * @SubsonicAPI
845
	 */
846
	private function getNewestPodcasts(int $count=20) {
847
		$episodes = $this->podcastService->getLatestEpisodes($this->userId, $count);
848
849
		return $this->subsonicResponse([
850
			'newestPodcasts' => [
851
				'episode' => Util::arrayMapMethod($episodes, 'toSubsonicApi')
852
			]
853
		]);
854
	}
855
856
	/**
857
	 * @SubsonicAPI
858
	 */
859
	private function refreshPodcasts() {
860
		$this->podcastService->updateAllChannels($this->userId);
861
		return $this->subsonicResponse([]);
862
	}
863
864
	/**
865
	 * @SubsonicAPI
866
	 */
867
	private function createPodcastChannel(string $url) {
868
		$result = $this->podcastService->subscribe($url, $this->userId);
869
870
		switch ($result['status']) {
871
			case PodcastService::STATUS_OK:
872
				return $this->subsonicResponse([]);
873
			case PodcastService::STATUS_INVALID_URL:
874
				throw new SubsonicException("Invalid URL $url", 0);
875
			case PodcastService::STATUS_INVALID_RSS:
876
				throw new SubsonicException("The document at URL $url is not a valid podcast RSS feed", 0);
877
			case PodcastService::STATUS_ALREADY_EXISTS:
878
				throw new SubsonicException('User already has this podcast channel subscribed', 0);
879
			default:
880
				throw new SubsonicException("Unexpected status code {$result['status']}", 0);
881
		}
882
	}
883
884
	/**
885
	 * @SubsonicAPI
886
	 */
887
	private function deletePodcastChannel(string $id) {
888
		$id = self::ripIdPrefix($id);
889
		$status = $this->podcastService->unsubscribe($id, $this->userId);
890
891
		switch ($status) {
892
			case PodcastService::STATUS_OK:
893
				return $this->subsonicResponse([]);
894
			case PodcastService::STATUS_NOT_FOUND:
895
				throw new SubsonicException('Channel to be deleted not found', 70);
896
			default:
897
				throw new SubsonicException("Unexpected status code $status", 0);
898
		}
899
	}
900
901
	/**
902
	 * @SubsonicAPI
903
	 */
904
	private function getBookmarks() {
905
		$bookmarkNodes = [];
906
		$bookmarks = $this->bookmarkBusinessLayer->findAll($this->userId);
907
908
		foreach ($bookmarks as $bookmark) {
909
			$node = $bookmark->toSubsonicApi();
910
			$entryId = $bookmark->getEntryId();
911
			$type = $bookmark->getType();
912
913
			try {
914
				if ($type === Bookmark::TYPE_TRACK) {
915
					$track = $this->trackBusinessLayer->find($entryId, $this->userId);
916
					$track->setAlbum($this->albumBusinessLayer->find($track->getAlbumId(), $this->userId));
917
					$node['entry'] = $track->toSubsonicApi($this->l10n);
918
				} elseif ($type === Bookmark::TYPE_PODCAST_EPISODE) {
919
					$node['entry'] = $this->podcastEpisodeBusinessLayer->find($entryId, $this->userId)->toSubsonicApi();
920
				} else {
921
					$this->logger->log("Bookmark {$bookmark->getId()} had unexpected entry type $type", 'warn');
922
				}
923
				$bookmarkNodes[] = $node;
924
			} catch (BusinessLayerException $e) {
925
				$this->logger->log("Bookmarked entry with type $type and id $entryId not found", 'warn');
926
			}
927
		}
928
929
		return $this->subsonicResponse(['bookmarks' => ['bookmark' => $bookmarkNodes]]);
930
	}
931
932
	/**
933
	 * @SubsonicAPI
934
	 */
935
	private function createBookmark(string $id, int $position, ?string $comment) {
936
		list($type, $entityId) = self::parseBookamrkIdParam($id);
937
		$this->bookmarkBusinessLayer->addOrUpdate($this->userId, $type, $entityId, $position, $comment);
938
		return $this->subsonicResponse([]);
939
	}
940
941
	/**
942
	 * @SubsonicAPI
943
	 */
944
	private function deleteBookmark(string $id) {
945
		list($type, $entityId) = self::parseBookamrkIdParam($id);
946
947
		$bookmark = $this->bookmarkBusinessLayer->findByEntry($type, $entityId, $this->userId);
948
		$this->bookmarkBusinessLayer->delete($bookmark->getId(), $this->userId);
949
950
		return $this->subsonicResponse([]);
951
	}
952
953
	/**
954
	 * @SubsonicAPI
955
	 */
956
	private function getPlayQueue() {
957
		// TODO: not supported yet
958
		return $this->subsonicResponse(['playQueue' => []]);
959
	}
960
961
	/**
962
	 * @SubsonicAPI
963
	 */
964
	private function savePlayQueue() {
965
		// TODO: not supported yet
966
		return $this->subsonicResponse([]);
967
	}
968
969
	/* -------------------------------------------------------------------------
970
	 * Helper methods
971
	 *------------------------------------------------------------------------*/
972
973
	private static function ensureParamHasValue(string $paramName, /*mixed*/ $paramValue) {
974
		if ($paramValue === null || $paramValue === '') {
975
			throw new SubsonicException("Required parameter '$paramName' missing", 10);
976
		}
977
	}
978
979
	private static function parseBookamrkIdParam(string $id) : array {
980
		list($typeName, $entityId) = \explode('-', $id);
981
982
		if ($typeName === 'track') {
983
			$type = Bookmark::TYPE_TRACK;
984
		} elseif ($typeName === 'podcast_episode') {
985
			$type = Bookmark::TYPE_PODCAST_EPISODE;
986
		} else {
987
			throw new SubsonicException("Unsupported ID format $id", 0);
988
		}
989
990
		return [$type, (int)$entityId];
991
	}
992
993
	/**
994
	 * Parse parameters used in the `star` and `unstar` API methods
995
	 */
996
	private static function parseStarringParameters(array $ids, array $albumIds, array $artistIds) {
997
		// album IDs from newer clients
998
		$albumIds = \array_map('self::ripIdPrefix', $albumIds);
999
1000
		// artist IDs from newer clients
1001
		$artistIds = \array_map('self::ripIdPrefix', $artistIds);
1002
1003
		// Song IDs from newer clients and song/folder/album/artist IDs from older clients are all packed in $ids.
1004
		// Also podcast IDs may come there; that is not documented part of the API but at least DSub does that.
1005
1006
		$trackIds = [];
1007
		$channelIds = [];
1008
		$episodeIds = [];
1009
1010
		foreach ($ids as $prefixedId) {
1011
			$parts = \explode('-', $prefixedId);
1012
			$type = $parts[0];
1013
			$id = (int)$parts[1];
1014
1015
			if ($type == 'track') {
1016
				$trackIds[] = $id;
1017
			} elseif ($type == 'album') {
1018
				$albumIds[] = $id;
1019
			} elseif ($type == 'artist') {
1020
				$artistIds[] = $id;
1021
			} elseif ($type == 'podcast_channel') {
1022
				$channelIds[] = $id;
1023
			} elseif ($type == 'podcast_episode') {
1024
				$episodeIds[] = $id;
1025
			} elseif ($type == 'folder') {
1026
				throw new SubsonicException('Starring folders is not supported', 0);
1027
			} else {
1028
				throw new SubsonicException("Unexpected ID format: $prefixedId", 0);
1029
			}
1030
		}
1031
1032
		return [
1033
			'tracks' => $trackIds,
1034
			'albums' => $albumIds,
1035
			'artists' => $artistIds,
1036
			'podcast_channels' => $channelIds,
1037
			'podcast_episodes' => $episodeIds
1038
		];
1039
	}
1040
1041
	private function getFilesystemNode($id) {
1042
		$rootFolder = $this->userMusicFolder->getFolder($this->userId);
1043
		$nodes = $rootFolder->getById($id);
1044
1045
		if (\count($nodes) != 1) {
1046
			throw new SubsonicException('file not found', 70);
1047
		}
1048
1049
		return $nodes[0];
1050
	}
1051
1052
	private static function getIndexingChar(?string $name) {
1053
		// For unknown artists, use '?'
1054
		$char = '?';
1055
1056
		if (!empty($name)) {
1057
			$char = \mb_convert_case(\mb_substr($name, 0, 1), MB_CASE_UPPER);
1058
		}
1059
		// Bundle all numeric characters together
1060
		if (\is_numeric($char)) {
1061
			$char = '#';
1062
		}
1063
1064
		return $char;
1065
	}
1066
1067
	private function getSubfoldersAndTracks(Folder $folder) : array {
1068
		$nodes = $folder->getDirectoryListing();
1069
		$subFolders = \array_filter($nodes, function ($n) {
1070
			return ($n instanceof Folder) && $this->userMusicFolder->pathBelongsToMusicLibrary($n->getPath(), $this->userId);
1071
		});
1072
1073
		$tracks = $this->trackBusinessLayer->findAllByFolder($folder->getId(), $this->userId);
1074
1075
		return [$subFolders, $tracks];
1076
	}
1077
1078
	private function getIndexesForFolders() {
1079
		$rootFolder = $this->userMusicFolder->getFolder($this->userId);
1080
1081
		list($subFolders, $tracks) = $this->getSubfoldersAndTracks($rootFolder);
1082
1083
		$indexes = [];
1084
		foreach ($subFolders as $folder) {
1085
			$indexes[self::getIndexingChar($folder->getName())][] = [
1086
				'name' => $folder->getName(),
1087
				'id' => 'folder-' . $folder->getId()
1088
			];
1089
		}
1090
1091
		$folders = [];
1092
		foreach ($indexes as $indexChar => $bucketArtists) {
1093
			$folders[] = ['name' => $indexChar, 'artist' => $bucketArtists];
1094
		}
1095
1096
		return $this->subsonicResponse(['indexes' => [
1097
			'index' => $folders,
1098
			'child' => $this->tracksToApi($tracks)
1099
		]]);
1100
	}
1101
1102
	private function getMusicDirectoryForFolder($id) {
1103
		$folderId = self::ripIdPrefix($id); // get rid of 'folder-' prefix
1104
		$folder = $this->getFilesystemNode($folderId);
1105
1106
		if (!($folder instanceof Folder)) {
1107
			throw new SubsonicException("$id is not a valid folder", 70);
1108
		}
1109
1110
		list($subFolders, $tracks) = $this->getSubfoldersAndTracks($folder);
1111
1112
		$children = \array_merge(
1113
			\array_map([$this, 'folderToApi'], $subFolders),
1114
			$this->tracksToApi($tracks)
1115
		);
1116
1117
		$content = [
1118
			'directory' => [
1119
				'id' => $id,
1120
				'name' => $folder->getName(),
1121
				'child' => $children
1122
			]
1123
		];
1124
1125
		// Parent folder ID is included if and only if the parent folder is not the top level
1126
		$rootFolderId = $this->userMusicFolder->getFolder($this->userId)->getId();
1127
		$parentFolderId = $folder->getParent()->getId();
1128
		if ($rootFolderId != $parentFolderId) {
1129
			$content['parent'] = 'folder-' . $parentFolderId;
1130
		}
1131
1132
		return $this->subsonicResponse($content);
1133
	}
1134
1135
	private function getIndexesForArtists($rootElementName = 'indexes') {
1136
		$artists = $this->artistBusinessLayer->findAllHavingAlbums($this->userId, SortBy::Name);
1137
1138
		$indexes = [];
1139
		foreach ($artists as $artist) {
1140
			$indexes[self::getIndexingChar($artist->getName())][] = $this->artistToApi($artist);
1141
		}
1142
1143
		$result = [];
1144
		foreach ($indexes as $indexChar => $bucketArtists) {
1145
			$result[] = ['name' => $indexChar, 'artist' => $bucketArtists];
1146
		}
1147
1148
		return $this->subsonicResponse([$rootElementName => ['index' => $result]]);
1149
	}
1150
1151
	private function getMusicDirectoryForArtist($id) {
1152
		$artistId = self::ripIdPrefix($id); // get rid of 'artist-' prefix
1153
1154
		$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
1155
		$albums = $this->albumBusinessLayer->findAllByArtist($artistId, $this->userId);
1156
1157
		return $this->subsonicResponse([
1158
			'directory' => [
1159
				'id' => $id,
1160
				'name' => $artist->getNameString($this->l10n),
1161
				'child' => \array_map([$this, 'albumToOldApi'], $albums)
1162
			]
1163
		]);
1164
	}
1165
1166
	private function getMusicDirectoryForAlbum($id) {
1167
		$albumId = self::ripIdPrefix($id); // get rid of 'album-' prefix
1168
1169
		$album = $this->albumBusinessLayer->find($albumId, $this->userId);
1170
		$albumName = $album->getNameString($this->l10n);
1171
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $this->userId);
1172
1173
		return $this->subsonicResponse([
1174
			'directory' => [
1175
				'id' => $id,
1176
				'parent' => 'artist-' . $album->getAlbumArtistId(),
1177
				'name' => $albumName,
1178
				'child' => \array_map(function ($track) use ($album) {
1179
					$track->setAlbum($album);
1180
					return $track->toSubsonicApi($this->l10n);
1181
				}, $tracks)
1182
			]
1183
		]);
1184
	}
1185
1186
	private function getMusicDirectoryForPodcastChannel($id) {
1187
		$channelId = self::ripIdPrefix($id); // get rid of 'podcast_channel-' prefix
1188
		$channel = $this->podcastService->getChannel($channelId, $this->userId, /*$includeEpisodes=*/ true);
1189
1190
		if ($channel === null) {
1191
			throw new SubsonicException("Podcast channel $channelId not found", 0);
1192
		}
1193
1194
		return $this->subsonicResponse([
1195
			'directory' => [
1196
				'id' => $id,
1197
				'name' => $channel->getTitle(),
1198
				'child' => Util::arrayMapMethod($channel->getEpisodes(), 'toSubsonicApi')
1199
			]
1200
		]);
1201
	}
1202
1203
	/**
1204
	 * @param Folder $folder
1205
	 * @return array
1206
	 */
1207
	private function folderToApi($folder) {
1208
		return [
1209
			'id' => 'folder-' . $folder->getId(),
1210
			'title' => $folder->getName(),
1211
			'isDir' => true
1212
		];
1213
	}
1214
1215
	/**
1216
	 * @param Artist $artist
1217
	 * @return array
1218
	 */
1219
	private function artistToApi($artist) {
1220
		$id = $artist->getId();
1221
		$result = [
1222
			'name' => $artist->getNameString($this->l10n),
1223
			'id' => $id ? ('artist-' . $id) : '-1', // getArtistInfo may show artists without ID
1224
			'albumCount' => $id ? $this->albumBusinessLayer->countByArtist($id) : 0,
1225
			'starred' => Util::formatZuluDateTime($artist->getStarred())
1226
		];
1227
1228
		if (!empty($artist->getCoverFileId())) {
1229
			$result['coverArt'] = $result['id'];
1230
			$result['artistImageUrl'] = $this->artistImageUrl($result['id']);
1231
		}
1232
1233
		return $result;
1234
	}
1235
1236
	/**
1237
	 * The "old API" format is used e.g. in getMusicDirectory and getAlbumList
1238
	 */
1239
	private function albumToOldApi(Album $album) : array {
1240
		$result = $this->albumCommonApiFields($album);
1241
1242
		$result['parent'] = 'artist-' . $album->getAlbumArtistId();
1243
		$result['title'] = $album->getNameString($this->l10n);
1244
		$result['isDir'] = true;
1245
1246
		return $result;
1247
	}
1248
1249
	/**
1250
	 * The "new API" format is used e.g. in getAlbum and getAlbumList2
1251
	 */
1252
	private function albumToNewApi(Album $album) : array {
1253
		$result = $this->albumCommonApiFields($album);
1254
1255
		$result['artistId'] = 'artist-' . $album->getAlbumArtistId();
1256
		$result['name'] = $album->getNameString($this->l10n);
1257
		$result['songCount'] = $this->trackBusinessLayer->countByAlbum($album->getId());
1258
		$result['duration'] = $this->trackBusinessLayer->totalDurationOfAlbum($album->getId());
1259
1260
		return $result;
1261
	}
1262
1263
	private function albumCommonApiFields(Album $album) : array {
1264
		$genreString = \implode(', ', \array_map(function (Genre $genre) {
1265
			return $genre->getNameString($this->l10n);
1266
		}, $album->getGenres() ?? []));
1267
1268
		return [
1269
			'id' => 'album-' . $album->getId(),
1270
			'artist' => $album->getAlbumArtistNameString($this->l10n),
1271
			'created' => Util::formatZuluDateTime($album->getCreated()),
1272
			'coverArt' => empty($album->getCoverFileId()) ? null : 'album-' . $album->getId(),
1273
			'starred' => Util::formatZuluDateTime($album->getStarred()),
1274
			'year' => $album->yearToAPI(),
1275
			'genre' => $genreString ?: null
1276
		];
1277
	}
1278
1279
	/**
1280
	 * @param Track[] $tracks
1281
	 * @return array
1282
	 */
1283
	private function tracksToApi(array $tracks) : array {
1284
		$this->albumBusinessLayer->injectAlbumsToTracks($tracks, $this->userId);
1285
		return Util::arrayMapMethod($tracks, 'toSubsonicApi', [$this->l10n]);
1286
	}
1287
1288
	/**
1289
	 * Common logic for getAlbumList and getAlbumList2
1290
	 * @return Album[]
1291
	 */
1292
	private function albumsForGetAlbumList(
1293
			string $type, ?string $genre, ?int $fromYear, ?int $toYear, int $size, int $offset) : array {
1294
		$size = \min($size, 500); // the API spec limits the maximum amount to 500
1295
1296
		$albums = [];
1297
1298
		switch ($type) {
1299
			case 'random':
1300
				$allAlbums = $this->albumBusinessLayer->findAll($this->userId);
1301
				$indices = $this->random->getIndices(\count($allAlbums), $offset, $size, $this->userId, 'subsonic_albums');
1302
				$albums = Util::arrayMultiGet($allAlbums, $indices);
1303
				break;
1304
			case 'starred':
1305
				$albums = $this->albumBusinessLayer->findAllStarred($this->userId, $size, $offset);
1306
				break;
1307
			case 'alphabeticalByName':
1308
				$albums = $this->albumBusinessLayer->findAll($this->userId, SortBy::Name, $size, $offset);
1309
				break;
1310
			case 'alphabeticalByArtist':
1311
				$albums = $this->albumBusinessLayer->findAll($this->userId, SortBy::Parent, $size, $offset);
1312
				break;
1313
			case 'byGenre':
1314
				self::ensureParamHasValue('genre', $genre);
1315
				$albums = $this->findAlbumsByGenre($genre, $size, $offset);
1316
				break;
1317
			case 'byYear':
1318
				self::ensureParamHasValue('fromYear', $fromYear);
1319
				self::ensureParamHasValue('toYear', $toYear);
1320
				$albums = $this->albumBusinessLayer->findAllByYearRange($fromYear, $toYear, $this->userId, $size, $offset);
1321
				break;
1322
			case 'newest':
1323
				$albums = $this->albumBusinessLayer->findAll($this->userId, SortBy::Newest, $size, $offset);
1324
				break;
1325
			case 'frequent':
1326
				$albums = $this->albumBusinessLayer->findFrequentPlay($this->userId, $size, $offset);
1327
				break;
1328
			case 'recent':
1329
				$albums = $this->albumBusinessLayer->findRecentPlay($this->userId, $size, $offset);
1330
				break;
1331
			case 'highest':
1332
				// TODO
1333
			default:
1334
				$this->logger->log("Album list type '$type' is not supported", 'debug');
1335
				break;
1336
		}
1337
1338
		return $albums;
1339
	}
1340
1341
	/**
1342
	 * Common logic for getArtistInfo and getArtistInfo2
1343
	 */
1344
	private function doGetArtistInfo(string $rootName, string $id, bool $includeNotPresent) {
1345
		$content = [];
1346
1347
		// This function may be called with a folder ID instead of an artist ID in case
1348
		// the library is being browsed by folders. In that case, return an empty response.
1349
		if (Util::startsWith($id, 'artist')) {
1350
			$artistId = self::ripIdPrefix($id); // get rid of 'artist-' prefix
1351
1352
			$info = $this->lastfmService->getArtistInfo($artistId, $this->userId);
1353
1354
			if (isset($info['artist'])) {
1355
				$content = [
1356
					'biography' => $info['artist']['bio']['summary'],
1357
					'lastFmUrl' => $info['artist']['url'],
1358
					'musicBrainzId' => $info['artist']['mbid'] ?? null
1359
				];
1360
1361
				$similarArtists = $this->lastfmService->getSimilarArtists($artistId, $this->userId, $includeNotPresent);
1362
				$content['similarArtist'] = \array_map([$this, 'artistToApi'], $similarArtists);
1363
			}
1364
1365
			$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
1366
			if ($artist->getCoverFileId() !== null) {
1367
				$content['largeImageUrl'] = [$this->artistImageUrl($id)];
1368
			}
1369
		}
1370
1371
		// This method is unusual in how it uses non-attribute elements in the response. On the other hand,
1372
		// all the details of the <similarArtist> elements are rendered as attributes. List those separately.
1373
		$attributeKeys = ['name', 'id', 'albumCount', 'coverArt', 'artistImageUrl', 'starred'];
1374
1375
		return $this->subsonicResponse([$rootName => $content], $attributeKeys);
1376
	}
1377
1378
	/**
1379
	 * Common logic for getAlbumInfo and getAlbumInfo2
1380
	 */
1381
	private function doGetAlbumInfo(string $rootName, string $id) {
1382
		$content = [];
1383
1384
		// This function may be called with a folder ID instead of an album ID in case
1385
		// the library is being browsed by folders. In that case, return an empty response.
1386
		if (Util::startsWith($id, 'album')) {
1387
			$albumId = self::ripIdPrefix($id); // get rid of 'album-' prefix
1388
1389
			$info = $this->lastfmService->getAlbumInfo($albumId, $this->userId);
1390
1391
			if (isset($info['album'])) {
1392
				$content = [
1393
					'notes' => $info['album']['wiki']['summary'] ?? null,
1394
					'lastFmUrl' => $info['album']['url'],
1395
					'musicBrainzId' => $info['album']['mbid'] ?? null
1396
				];
1397
1398
				foreach ($info['album']['image'] ?? [] as $imageInfo) {
1399
					if (!empty($imageInfo['size'])) {
1400
						$content[$imageInfo['size'] . 'ImageUrl'] = $imageInfo['#text'];
1401
					}
1402
				}
1403
			}
1404
		}
1405
1406
		// This method is unusual in how it uses non-attribute elements in the response.
1407
		return $this->subsonicResponse([$rootName => $content], []);
1408
	}
1409
1410
	/**
1411
	 * Common logic for getSimilarSongs and getSimilarSongs2
1412
	 */
1413
	private function doGetSimilarSongs(string $rootName, string $id, int $count) {
1414
		if (Util::startsWith($id, 'artist')) {
1415
			$artistId = self::ripIdPrefix($id);
1416
		} elseif (Util::startsWith($id, 'album')) {
1417
			$albumId = self::ripIdPrefix($id);
1418
			$artistId = $this->albumBusinessLayer->find($albumId, $this->userId)->getAlbumArtistId();
1419
		} elseif (Util::startsWith($id, 'track')) {
1420
			$trackId = self::ripIdPrefix($id);
1421
			$artistId = $this->trackBusinessLayer->find($trackId, $this->userId)->getArtistId();
1422
		} else {
1423
			throw new SubsonicException("Id $id has a type not supported on getSimilarSongs", 0);
1424
		}
1425
1426
		$artists = $this->lastfmService->getSimilarArtists($artistId, $this->userId);
1427
		$artists[] = $this->artistBusinessLayer->find($artistId, $this->userId);
1428
1429
		// Get all songs by the found artists
1430
		$songs = [];
1431
		foreach ($artists as $artist) {
1432
			$songs = \array_merge($songs, $this->trackBusinessLayer->findAllByArtist($artist->getId(), $this->userId));
1433
		}
1434
1435
		// Randomly select the desired number of songs
1436
		$songs = $this->random->pickItems($songs, $count);
1437
1438
		return $this->subsonicResponse([$rootName => [
1439
			'song' => $this->tracksToApi($songs)
1440
		]]);
1441
	}
1442
1443
	/**
1444
	 * Common logic for search2 and search3
1445
	 * @return array with keys 'artists', 'albums', and 'tracks'
1446
	 */
1447
	private function doSearch(string $query, int $artistCount, int $artistOffset,
1448
			int $albumCount, int $albumOffset, int $songCount, int $songOffset) {
1449
1450
		if (empty($query)) {
1451
			throw new SubsonicException("The 'query' argument is mandatory", 10);
1452
		}
1453
1454
		return [
1455
			'artists' => $this->artistBusinessLayer->findAllByName($query, $this->userId, true, $artistCount, $artistOffset),
1456
			'albums' => $this->albumBusinessLayer->findAllByName($query, $this->userId, true, $albumCount, $albumOffset),
1457
			'tracks' => $this->trackBusinessLayer->findAllByName($query, $this->userId, true, $songCount, $songOffset)
1458
		];
1459
	}
1460
1461
	/**
1462
	 * Common logic for getStarred and getStarred2
1463
	 * @return array
1464
	 */
1465
	private function doGetStarred() {
1466
		return [
1467
			'artists' => $this->artistBusinessLayer->findAllStarred($this->userId),
1468
			'albums' => $this->albumBusinessLayer->findAllStarred($this->userId),
1469
			'tracks' => $this->trackBusinessLayer->findAllStarred($this->userId)
1470
		];
1471
	}
1472
1473
	/**
1474
	 * Common response building logic for search2, search3, getStarred, and getStarred2
1475
	 * @param string $title Name of the main node in the response message
1476
	 * @param array $results Search results with keys 'artists', 'albums', and 'tracks'
1477
	 * @param boolean $useNewApi Set to true for search3 and getStarred2. There is a difference
1478
	 *                           in the formatting of the album nodes.
1479
	 * @return \OCP\AppFramework\Http\Response
1480
	 */
1481
	private function searchResponse($title, $results, $useNewApi) {
1482
		$albumMapFunc = $useNewApi ? 'albumToNewApi' : 'albumToOldApi';
1483
1484
		return $this->subsonicResponse([$title => [
1485
			'artist' => \array_map([$this, 'artistToApi'], $results['artists']),
1486
			'album' => \array_map([$this, $albumMapFunc], $results['albums']),
1487
			'song' => $this->tracksToApi($results['tracks'])
1488
		]]);
1489
	}
1490
1491
	/**
1492
	 * Find tracks by genre name
1493
	 * @param string $genreName
1494
	 * @param int|null $limit
1495
	 * @param int|null $offset
1496
	 * @return Track[]
1497
	 */
1498
	private function findTracksByGenre($genreName, $limit=null, $offset=null) {
1499
		$genre = $this->findGenreByName($genreName);
1500
1501
		if ($genre) {
1502
			return $this->trackBusinessLayer->findAllByGenre($genre->getId(), $this->userId, $limit, $offset);
1503
		} else {
1504
			return [];
1505
		}
1506
	}
1507
1508
	/**
1509
	 * Find albums by genre name
1510
	 * @param string $genreName
1511
	 * @param int|null $limit
1512
	 * @param int|null $offset
1513
	 * @return Album[]
1514
	 */
1515
	private function findAlbumsByGenre($genreName, $limit=null, $offset=null) {
1516
		$genre = $this->findGenreByName($genreName);
1517
1518
		if ($genre) {
1519
			return $this->albumBusinessLayer->findAllByGenre($genre->getId(), $this->userId, $limit, $offset);
1520
		} else {
1521
			return [];
1522
		}
1523
	}
1524
1525
	private function findGenreByName($name) {
1526
		$genreArr = $this->genreBusinessLayer->findAllByName($name, $this->userId);
1527
		if (\count($genreArr) == 0 && $name == Genre::unknownNameString($this->l10n)) {
1528
			$genreArr = $this->genreBusinessLayer->findAllByName('', $this->userId);
1529
		}
1530
		return \count($genreArr) ? $genreArr[0] : null;
1531
	}
1532
1533
	private function artistImageUrl(string $id) : string {
1534
		$par = $this->request->getParams();
1535
		return $this->urlGenerator->linkToRouteAbsolute('music.subsonic.handleRequest', ['method' => 'getCoverArt'])
1536
			. "?u={$par['u']}&p={$par['p']}&v={$par['v']}&c={$par['c']}&id=$id&size=" . CoverHelper::DO_NOT_CROP_OR_SCALE;
1537
		// Note: Using DO_NOT_CROP_OR_SCALE (-1) as size is our proprietary extension and not part of the Subsonic API
1538
	}
1539
1540
	/**
1541
	 * Given a prefixed ID like 'artist-123' or 'track-45', return just the numeric part.
1542
	 */
1543
	private static function ripIdPrefix(string $id) : int {
1544
		return (int)(\explode('-', $id)[1]);
1545
	}
1546
1547
	private function subsonicResponse($content, $useAttributes=true, $status = 'ok') {
1548
		$content['status'] = $status;
1549
		$content['version'] = self::API_VERSION;
1550
		$responseData = ['subsonic-response' => Util::arrayRejectRecursive($content, 'is_null')];
1551
1552
		if ($this->format == 'json') {
1553
			$response = new JSONResponse($responseData);
1554
		} elseif ($this->format == 'jsonp') {
1555
			$responseData = \json_encode($responseData);
1556
			$response = new DataDisplayResponse("{$this->callback}($responseData);");
1557
			$response->addHeader('Content-Type', 'text/javascript; charset=UTF-8');
1558
		} else {
1559
			if (\is_array($useAttributes)) {
1560
				$useAttributes = \array_merge($useAttributes, ['status', 'version']);
1561
			}
1562
			$response = new XmlResponse($responseData, $useAttributes);
1563
		}
1564
1565
		return $response;
1566
	}
1567
1568
	public function subsonicErrorResponse($errorCode, $errorMessage) {
1569
		return $this->subsonicResponse([
1570
				'error' => [
1571
					'code' => $errorCode,
1572
					'message' => $errorMessage
1573
				]
1574
			], true, 'failed');
1575
	}
1576
}
1577