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