Passed
Push — master ( 94efe3...da4c60 )
by Pauli
02:57
created

SubsonicController::getAlbumInfo()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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