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

SubsonicController::albumToOldApi()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 5
nc 1
nop 1
dl 0
loc 8
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 - 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