Passed
Push — master ( 5b46ab...f9c9de )
by Pauli
09:30
created

SubsonicController::getSubfoldersAndTracks()   A

Complexity

Conditions 2
Paths 1

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

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