Passed
Push — master ( 536b5b...318c56 )
by Pauli
02:46
created

SubsonicController::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 42
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Importance

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

How to fix   Many Parameters   

Many Parameters

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

There are several approaches to avoid long parameter lists:

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