Passed
Push — master ( 23afda...b1ae99 )
by Pauli
03:08
created

SubsonicController::getPodcastEpisode()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

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

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

1767
		$token = $this->imageService->getToken('artist', $id, /** @scrutinizer ignore-type */ $this->keyId);
Loading history...
1768
		return $this->urlGenerator->linkToRouteAbsolute('music.ampacheImage.image',
1769
			['object_type' => 'artist', 'object_id' => $id, 'token' => $token, 'size' => CoverHelper::DO_NOT_CROP_OR_SCALE]);
1770
	}
1771
1772
	/**
1773
	 * Given a prefixed ID like 'artist-123' or 'track-45', return the string part and the numeric part.
1774
	 * @throws SubsonicException if the \a $id doesn't follow the expected pattern
1775
	 */
1776
	private static function parseEntityId(string $id) : array {
1777
		$parts = \explode('-', $id);
1778
		if (\count($parts) !== 2) {
1779
			throw new SubsonicException("Unexpected ID format: $id", 0);
1780
		}
1781
		$parts[1] = (int)$parts[1];
1782
		return $parts;
1783
	}
1784
1785
	/**
1786
	 * Given a prefixed ID like 'artist-123' or 'track-45', return just the numeric part.
1787
	 */
1788
	private static function ripIdPrefix(string $id) : int {
1789
		return self::parseEntityId($id)[1];
1790
	}
1791
1792
	private function subsonicResponse($content, $useAttributes=true, $status = 'ok') {
1793
		$content['status'] = $status;
1794
		$content['version'] = self::API_VERSION;
1795
		$content['type'] = AppInfo::getFullName();
1796
		$content['serverVersion'] = AppInfo::getVersion();
1797
		$content['openSubsonic'] = true;
1798
		$responseData = ['subsonic-response' => Util::arrayRejectRecursive($content, 'is_null')];
1799
1800
		if ($this->format == 'json') {
1801
			$response = new JSONResponse($responseData);
1802
		} elseif ($this->format == 'jsonp') {
1803
			$responseData = \json_encode($responseData);
1804
			$response = new DataDisplayResponse("{$this->callback}($responseData);");
1805
			$response->addHeader('Content-Type', 'text/javascript; charset=UTF-8');
1806
		} else {
1807
			if (\is_array($useAttributes)) {
1808
				$useAttributes = \array_merge($useAttributes, ['status', 'version', 'type', 'serverVersion', 'xmlns']);
1809
			}
1810
			$responseData['subsonic-response']['xmlns'] = 'http://subsonic.org/restapi';
1811
			$response = new XmlResponse($responseData, $useAttributes);
1812
		}
1813
1814
		return $response;
1815
	}
1816
1817
	public function subsonicErrorResponse($errorCode, $errorMessage) {
1818
		return $this->subsonicResponse([
1819
				'error' => [
1820
					'code' => $errorCode,
1821
					'message' => $errorMessage
1822
				]
1823
			], true, 'failed');
1824
	}
1825
}
1826