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

SubsonicController::findGenreByName()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 6
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

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