Passed
Push — master ( 0837cd...cb2cf5 )
by Pauli
02:00
created

SubsonicController::getArtistInfo2()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 2

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 0
dl 0
loc 2
ccs 0
cts 2
cp 0
crap 2
rs 10
c 0
b 0
f 0
1
<?php
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, 2020
11
 */
12
13
namespace OCA\Music\Controller;
14
15
use \OCP\AppFramework\Controller;
0 ignored issues
show
Bug introduced by
The type OCP\AppFramework\Controller was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
16
use \OCP\AppFramework\Http\DataDisplayResponse;
0 ignored issues
show
Bug introduced by
The type OCP\AppFramework\Http\DataDisplayResponse was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
17
use \OCP\AppFramework\Http\JSONResponse;
0 ignored issues
show
Bug introduced by
The type OCP\AppFramework\Http\JSONResponse was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
18
use \OCP\Files\File;
0 ignored issues
show
Bug introduced by
The type OCP\Files\File was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
19
use \OCP\Files\Folder;
0 ignored issues
show
Bug introduced by
The type OCP\Files\Folder was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
20
use \OCP\IRequest;
0 ignored issues
show
Bug introduced by
The type OCP\IRequest was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
21
use \OCP\IURLGenerator;
0 ignored issues
show
Bug introduced by
The type OCP\IURLGenerator was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
22
23
use \OCA\Music\AppFramework\Core\Logger;
24
use \OCA\Music\AppFramework\Utility\MethodAnnotationReader;
25
26
use \OCA\Music\BusinessLayer\AlbumBusinessLayer;
27
use \OCA\Music\BusinessLayer\ArtistBusinessLayer;
28
use \OCA\Music\BusinessLayer\BookmarkBusinessLayer;
29
use \OCA\Music\BusinessLayer\GenreBusinessLayer;
30
use \OCA\Music\BusinessLayer\Library;
31
use \OCA\Music\BusinessLayer\PlaylistBusinessLayer;
32
use \OCA\Music\BusinessLayer\TrackBusinessLayer;
33
34
use \OCA\Music\Db\Album;
35
use \OCA\Music\Db\Artist;
36
use \OCA\Music\Db\Bookmark;
37
use \OCA\Music\Db\Genre;
38
use \OCA\Music\Db\Playlist;
39
use \OCA\Music\Db\SortBy;
40
use \OCA\Music\Db\Track;
41
42
use \OCA\Music\Http\FileResponse;
43
use \OCA\Music\Http\XMLResponse;
44
45
use \OCA\Music\Middleware\SubsonicException;
46
47
use \OCA\Music\Utility\CoverHelper;
48
use \OCA\Music\Utility\DetailsHelper;
49
use \OCA\Music\Utility\LastfmService;
50
use \OCA\Music\Utility\Random;
51
use \OCA\Music\Utility\UserMusicFolder;
52
use \OCA\Music\Utility\Util;
53
54
class SubsonicController extends Controller {
55
	const API_VERSION = '1.11.0';
56
57
	private $albumBusinessLayer;
58
	private $artistBusinessLayer;
59
	private $bookmarkBusinessLayer;
60
	private $genreBusinessLayer;
61
	private $playlistBusinessLayer;
62
	private $trackBusinessLayer;
63
	private $library;
64
	private $urlGenerator;
65
	private $userMusicFolder;
66
	private $l10n;
67
	private $coverHelper;
68
	private $detailsHelper;
69
	private $lastfmService;
70
	private $random;
71
	private $logger;
72
	private $userId;
73
	private $format;
74
	private $callback;
75
	private $timezone;
76
77
	public function __construct($appname,
78
								IRequest $request,
79
								$l10n,
80
								IURLGenerator $urlGenerator,
81
								AlbumBusinessLayer $albumBusinessLayer,
82
								ArtistBusinessLayer $artistBusinessLayer,
83
								BookmarkBusinessLayer $bookmarkBusinessLayer,
84
								GenreBusinessLayer $genreBusinessLayer,
85
								PlaylistBusinessLayer $playlistBusinessLayer,
86
								TrackBusinessLayer $trackBusinessLayer,
87
								Library $library,
88
								UserMusicFolder $userMusicFolder,
89
								CoverHelper $coverHelper,
90
								DetailsHelper $detailsHelper,
91
								LastfmService $lastfmService,
92
								Random $random,
93
								Logger $logger) {
94
		parent::__construct($appname, $request);
95
96
		$this->albumBusinessLayer = $albumBusinessLayer;
97
		$this->artistBusinessLayer = $artistBusinessLayer;
98
		$this->bookmarkBusinessLayer = $bookmarkBusinessLayer;
99
		$this->genreBusinessLayer = $genreBusinessLayer;
100
		$this->playlistBusinessLayer = $playlistBusinessLayer;
101
		$this->trackBusinessLayer = $trackBusinessLayer;
102
		$this->library = $library;
103
		$this->urlGenerator = $urlGenerator;
104
		$this->l10n = $l10n;
105
		$this->userMusicFolder = $userMusicFolder;
106
		$this->coverHelper = $coverHelper;
107
		$this->detailsHelper = $detailsHelper;
108
		$this->lastfmService = $lastfmService;
109
		$this->random = $random;
110
		$this->logger = $logger;
111
112
		// For timestamps in the Subsonic API, we would prefer to use the local timezone,
113
		// but the core has set the default timezone as 'UTC'. Get the timezone from php.ini
114
		// if available, and store it for later use.
115
		$tz = \ini_get('date.timezone') ?: 'UTC';
116
		$this->timezone = new \DateTimeZone($tz);
117
	}
118
119
	/**
120
	 * Called by the middleware to set the reponse format to be used
121
	 * @param string $format Response format: xml/json/jsonp
122
	 * @param string|null $callback Function name to use if the @a $format is 'jsonp'
123
	 */
124
	public function setResponseFormat($format, $callback) {
125
		$this->format = $format;
126
		$this->callback = $callback;
127
	}
128
129
	/**
130
	 * Called by the middleware once the user credentials have been checked
131
	 * @param string $userId
132
	 */
133
	public function setAuthenticatedUser($userId) {
134
		$this->userId = $userId;
135
	}
136
137
	/**
138
	 * @NoAdminRequired
139
	 * @PublicPage
140
	 * @NoCSRFRequired
141
	 */
142
	public function handleRequest($method) {
143
		$this->logger->log("Subsonic request $method", 'debug');
144
145
		// Allow calling all methods with or without the postfix ".view"
146
		if (Util::endsWith($method, ".view")) {
147
			$method = \substr($method, 0, -\strlen(".view"));
148
		}
149
150
		// Allow calling any functions annotated to be part of the API
151
		if (\method_exists($this, $method)) {
152
			$annotationReader = new MethodAnnotationReader($this, $method);
153
			if ($annotationReader->hasAnnotation('SubsonicAPI')) {
154
				return $this->$method();
155
			}
156
		}
157
158
		$this->logger->log("Request $method not supported", 'warn');
159
		return $this->subsonicErrorResponse(70, "Requested action $method is not supported");
160
	}
161
162
	/* -------------------------------------------------------------------------
163
	 * REST API methods
164
	 *------------------------------------------------------------------------*/
165
166
	/**
167
	 * @SubsonicAPI
168
	 */
169
	private function ping() {
170
		return $this->subsonicResponse([]);
171
	}
172
173
	/**
174
	 * @SubsonicAPI
175
	 */
176
	private function getLicense() {
177
		return $this->subsonicResponse([
178
			'license' => [
179
				'valid' => 'true'
180
			]
181
		]);
182
	}
183
184
	/**
185
	 * @SubsonicAPI
186
	 */
187
	private function getMusicFolders() {
188
		// Only single root folder is supported
189
		return $this->subsonicResponse([
190
			'musicFolders' => ['musicFolder' => [
191
				['id' => 'artists', 'name' => $this->l10n->t('Artists')],
192
				['id' => 'folders', 'name' => $this->l10n->t('Folders')]
193
			]]
194
		]);
195
	}
196
197
	/**
198
	 * @SubsonicAPI
199
	 */
200
	private function getIndexes() {
201
		$id = $this->request->getParam('musicFolderId');
202
203
		if ($id === 'folders') {
204
			return $this->getIndexesForFolders();
205
		} else {
206
			return $this->getIndexesForArtists();
207
		}
208
	}
209
210
	/**
211
	 * @SubsonicAPI
212
	 */
213
	private function getMusicDirectory() {
214
		$id = $this->getRequiredParam('id');
215
216
		if (Util::startsWith($id, 'folder-')) {
217
			return $this->getMusicDirectoryForFolder($id);
218
		} elseif (Util::startsWith($id, 'artist-')) {
219
			return $this->getMusicDirectoryForArtist($id);
220
		} else {
221
			return $this->getMusicDirectoryForAlbum($id);
222
		}
223
	}
224
225
	/**
226
	 * @SubsonicAPI
227
	 */
228
	private function getAlbumList() {
229
		$albums = $this->albumsForGetAlbumList();
230
		return $this->subsonicResponse(['albumList' =>
231
				['album' => \array_map([$this, 'albumToOldApi'], $albums)]
232
		]);
233
	}
234
235
	/**
236
	 * @SubsonicAPI
237
	 */
238
	private function getAlbumList2() {
239
		/*
240
		 * According to the API specification, the difference between this and getAlbumList
241
		 * should be that this function would organize albums according the metadata while
242
		 * getAlbumList would organize them by folders. However, we organize by metadata
243
		 * also in getAlbumList, because that's more natural for the Music app and many/most
244
		 * clients do not support getAlbumList2.
245
		 */
246
		$albums = $this->albumsForGetAlbumList();
247
		return $this->subsonicResponse(['albumList2' =>
248
				['album' => \array_map([$this, 'albumToNewApi'], $albums)]
249
		]);
250
	}
251
252
	/**
253
	 * @SubsonicAPI
254
	 */
255
	private function getArtists() {
256
		return $this->getIndexesForArtists('artists');
257
	}
258
259
	/**
260
	 * @SubsonicAPI
261
	 */
262
	private function getArtist() {
263
		$id = $this->getRequiredParam('id');
264
		$artistId = self::ripIdPrefix($id); // get rid of 'artist-' prefix
265
266
		$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
267
		$albums = $this->albumBusinessLayer->findAllByArtist($artistId, $this->userId);
268
269
		$artistNode = $this->artistToApi($artist);
270
		$artistNode['album'] = \array_map([$this, 'albumToNewApi'], $albums);
271
272
		return $this->subsonicResponse(['artist' => $artistNode]);
273
	}
274
275
	/**
276
	 * @SubsonicAPI
277
	 */
278
	private function getArtistInfo() {
279
		return $this->doGetArtistInfo('artistInfo');
280
	}
281
282
	/**
283
	 * @SubsonicAPI
284
	 */
285
	private function getArtistInfo2() {
286
		return $this->doGetArtistInfo('artistInfo2');
287
	}
288
289
	/**
290
	 * @SubsonicAPI
291
	 */
292
	private function getSimilarSongs() {
293
		return $this->doGetSimilarSongs('similarSongs');
294
	}
295
296
	/**
297
	 * @SubsonicAPI
298
	 */
299
	private function getSimilarSongs2() {
300
		return $this->doGetSimilarSongs('similarSongs2');
301
	}
302
303
	/**
304
	 * @SubsonicAPI
305
	 */
306
	private function getAlbum() {
307
		$id = $this->getRequiredParam('id');
308
		$albumId = self::ripIdPrefix($id); // get rid of 'album-' prefix
309
310
		$album = $this->albumBusinessLayer->find($albumId, $this->userId);
311
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $this->userId);
312
313
		$albumNode = $this->albumToNewApi($album);
314
		$albumNode['song'] = \array_map(function($track) use ($album) {
315
			$track->setAlbum($album);
316
			return $this->trackToApi($track);
317
		}, $tracks);
318
		return $this->subsonicResponse(['album' => $albumNode]);
319
	}
320
321
	/**
322
	 * @SubsonicAPI
323
	 */
324
	private function getSong() {
325
		$id = $this->getRequiredParam('id');
326
		$trackId = self::ripIdPrefix($id); // get rid of 'track-' prefix
327
		$track = $this->trackBusinessLayer->find($trackId, $this->userId);
328
329
		return $this->subsonicResponse(['song' => $this->trackToApi($track)]);
330
	}
331
332
	/**
333
	 * @SubsonicAPI
334
	 */
335
	private function getRandomSongs() {
336
		$size = $this->request->getParam('size', 10);
337
		$size = \min($size, 500); // the API spec limits the maximum amount to 500
338
		$genre = $this->request->getParam('genre');
339
		$fromYear = $this->request->getParam('fromYear');
340
		$toYear = $this->request->getParam('toYear');
341
342
		if ($genre !== null) {
343
			$trackPool = $this->findTracksByGenre($genre);
344
		} else {
345
			$trackPool = $this->trackBusinessLayer->findAll($this->userId);
346
		}
347
348
		if ($fromYear !== null) {
349
			$trackPool = \array_filter($trackPool, function($track) use ($fromYear) {
350
				return ($track->getYear() !== null && $track->getYear() >= $fromYear);
351
			});
352
		}
353
354
		if ($toYear !== null) {
355
			$trackPool = \array_filter($trackPool, function($track) use ($toYear) {
356
				return ($track->getYear() !== null && $track->getYear() <= $toYear);
357
			});
358
		}
359
360
		$tracks = Random::pickItems($trackPool, $size);
361
362
		return $this->subsonicResponse(['randomSongs' =>
363
				['song' => \array_map([$this, 'trackToApi'], $tracks)]
364
		]);
365
	}
366
367
	/**
368
	 * @SubsonicAPI
369
	 */
370
	private function getCoverArt() {
371
		$id = $this->getRequiredParam('id');
372
		$size = $this->request->getParam('size');
373
374
		$idParts = \explode('-', $id);
375
		$type = $idParts[0];
376
		$entityId = (int)($idParts[1]);
377
378
		if ($type == 'album') {
379
			$entity = $this->albumBusinessLayer->find($entityId, $this->userId);
380
		} elseif ($type == 'artist') {
381
			$entity = $this->artistBusinessLayer->find($entityId, $this->userId);
382
		}
383
384
		if (!empty($entity)) {
385
			$rootFolder = $this->userMusicFolder->getFolder($this->userId);
386
			$coverData = $this->coverHelper->getCover($entity, $this->userId, $rootFolder, $size);
387
388
			if ($coverData !== null) {
389
				return new FileResponse($coverData);
390
			}
391
		}
392
393
		return $this->subsonicErrorResponse(70, "entity $id has no cover");
394
	}
395
396
	/**
397
	 * @SubsonicAPI
398
	 */
399
	private function getLyrics() {
400
		$artistPar = $this->request->getParam('artist');
401
		$titlePar = $this->request->getParam('title');
402
403
		$matches = $this->trackBusinessLayer->findAllByNameAndArtistName($titlePar, $artistPar, $this->userId);
404
		$matchingCount = \count($matches);
405
406
		if ($matchingCount === 0) {
407
			$this->logger->log("No matching track for title '$titlePar' and artist '$artistPar'", 'debug');
408
			return $this->subsonicResponse(['lyrics' => new \stdClass]);
409
		}
410
		else {
411
			if ($matchingCount > 1) {
412
				$this->logger->log("Found $matchingCount tracks matching title ".
413
									"'$titlePar' and artist '$artistPar'; using the first", 'debug');
414
			}
415
			$track = $matches[0];
416
417
			$artist = $this->artistBusinessLayer->find($track->getArtistId(), $this->userId);
418
			$rootFolder = $this->userMusicFolder->getFolder($this->userId);
419
			$lyrics = $this->detailsHelper->getLyrics($track->getFileId(), $rootFolder);
420
421
			return $this->subsonicResponse(['lyrics' => [
422
					'artist' => $artist->getNameString($this->l10n),
423
					'title' => $track->getTitle(),
424
					'value' => $lyrics
425
			]]);
426
		}
427
	}
428
429
	/**
430
	 * @SubsonicAPI
431
	 */
432
	private function stream() {
433
		// We don't support transcaoding, so 'stream' and 'download' act identically
434
		return $this->download();
435
	}
436
437
	/**
438
	 * @SubsonicAPI
439
	 */
440
	private function download() {
441
		$id = $this->getRequiredParam('id');
442
		$trackId = self::ripIdPrefix($id); // get rid of 'track-' prefix
443
444
		$track = $this->trackBusinessLayer->find($trackId, $this->userId);
445
		$file = $this->getFilesystemNode($track->getFileId());
446
447
		if ($file instanceof File) {
448
			return new FileResponse($file);
449
		} else {
450
			return $this->subsonicErrorResponse(70, 'file not found');
451
		}
452
	}
453
454
	/**
455
	 * @SubsonicAPI
456
	 */
457
	private function search2() {
458
		$results = $this->doSearch();
459
		return $this->searchResponse('searchResult2', $results, /*$useNewApi=*/false);
460
	}
461
462
	/**
463
	 * @SubsonicAPI
464
	 */
465
	private function search3() {
466
		$results = $this->doSearch();
467
		return $this->searchResponse('searchResult3', $results, /*$useNewApi=*/true);
468
	}
469
470
	/**
471
	 * @SubsonicAPI
472
	 */
473
	private function getGenres() {
474
		$genres = $this->genreBusinessLayer->findAll($this->userId, SortBy::Name);
475
476
		return $this->subsonicResponse(['genres' =>
477
			[
478
				'genre' => \array_map(function($genre) {
479
					return [
480
						'songCount' => $genre->getTrackCount(),
481
						'albumCount' => $genre->getAlbumCount(),
482
						'value' => $genre->getNameString($this->l10n)
483
					];
484
				},
485
				$genres)
486
			]
487
		]);
488
	}
489
490
	/**
491
	 * @SubsonicAPI
492
	 */
493
	private function getSongsByGenre() {
494
		$genre = $this->getRequiredParam('genre');
495
		$count = $this->request->getParam('count', 10);
496
		$offset = $this->request->getParam('offset', 0);
497
498
		$tracks = $this->findTracksByGenre($genre, $count, $offset);
499
500
		return $this->subsonicResponse(['songsByGenre' =>
501
			['song' => \array_map([$this, 'trackToApi'], $tracks)]
502
		]);
503
	}
504
505
	/**
506
	 * @SubsonicAPI
507
	 */
508
	private function getPlaylists() {
509
		$playlists = $this->playlistBusinessLayer->findAll($this->userId);
510
511
		return $this->subsonicResponse(['playlists' =>
512
			['playlist' => \array_map([$this, 'playlistToApi'], $playlists)]
513
		]);
514
	}
515
516
	/**
517
	 * @SubsonicAPI
518
	 */
519
	private function getPlaylist() {
520
		$id = $this->getRequiredParam('id');
521
		$playlist = $this->playlistBusinessLayer->find($id, $this->userId);
522
		$tracks = $this->playlistBusinessLayer->getPlaylistTracks($id, $this->userId);
523
524
		$playlistNode = $this->playlistToApi($playlist);
525
		$playlistNode['entry'] = \array_map([$this, 'trackToApi'], $tracks);
526
527
		return $this->subsonicResponse(['playlist' => $playlistNode]);
528
	}
529
530
	/**
531
	 * @SubsonicAPI
532
	 */
533
	private function createPlaylist() {
534
		$name = $this->getRequiredParam('name');
535
		$songIds = $this->getRepeatedParam('songId');
536
		$songIds = \array_map('self::ripIdPrefix', $songIds);
537
538
		$playlist = $this->playlistBusinessLayer->create($name, $this->userId);
539
		$this->playlistBusinessLayer->addTracks($songIds, $playlist->getId(), $this->userId);
540
541
		return $this->subsonicResponse([]);
542
	}
543
544
	/**
545
	 * @SubsonicAPI
546
	 */
547
	private function updatePlaylist() {
548
		$listId = $this->getRequiredParam('playlistId');
549
		$newName = $this->request->getParam('name');
550
		$newComment = $this->request->getParam('comment');
551
		$songIdsToAdd = $this->getRepeatedParam('songIdToAdd');
552
		$songIdsToAdd = \array_map('self::ripIdPrefix', $songIdsToAdd);
553
		$songIndicesToRemove = $this->getRepeatedParam('songIndexToRemove');
554
555
		if (!empty($newName)) {
556
			$this->playlistBusinessLayer->rename($newName, $listId, $this->userId);
557
		}
558
559
		if ($newComment !== null) {
560
			$this->playlistBusinessLayer->setComment($newComment, $listId, $this->userId);
561
		}
562
563
		if (!empty($songIndicesToRemove)) {
564
			$this->playlistBusinessLayer->removeTracks($songIndicesToRemove, $listId, $this->userId);
565
		}
566
567
		if (!empty($songIdsToAdd)) {
568
			$this->playlistBusinessLayer->addTracks($songIdsToAdd, $listId, $this->userId);
569
		}
570
571
		return $this->subsonicResponse([]);
572
	}
573
574
	/**
575
	 * @SubsonicAPI
576
	 */
577
	private function deletePlaylist() {
578
		$id = $this->getRequiredParam('id');
579
		$this->playlistBusinessLayer->delete($id, $this->userId);
580
		return $this->subsonicResponse([]);
581
	}
582
583
	/**
584
	 * @SubsonicAPI
585
	 */
586
	private function getUser() {
587
		$username = $this->getRequiredParam('username');
588
589
		if ($username != $this->userId) {
590
			throw new SubsonicException("{$this->userId} is not authorized to get details for other users.", 50);
591
		}
592
593
		return $this->subsonicResponse([
594
			'user' => [
595
				'username' => $username,
596
				'email' => '',
597
				'scrobblingEnabled' => false,
598
				'adminRole' => false,
599
				'settingsRole' => false,
600
				'downloadRole' => true,
601
				'uploadRole' => false,
602
				'playlistRole' => true,
603
				'coverArtRole' => false,
604
				'commentRole' => false,
605
				'podcastRole' => false,
606
				'streamRole' => true,
607
				'jukeboxRole' => false,
608
				'shareRole' => false,
609
				'videoConversionRole' => false,
610
				'folder' => ['artists', 'folders'],
611
			]
612
		]);
613
	}
614
615
	/**
616
	 * @SubsonicAPI
617
	 */
618
	private function getUsers() {
619
		throw new SubsonicException("{$this->userId} is not authorized to get details for other users.", 50);
620
	}
621
622
	/**
623
	 * @SubsonicAPI
624
	 */
625
	private function getAvatar() {
626
		// TODO: Use 'username' parameter to fetch user-specific avatar from the OC core.
627
		// Remember to check the permission.
628
		// For now, use the Music app logo for all users.
629
		$fileName = \join(DIRECTORY_SEPARATOR, [\dirname(__DIR__), 'img', 'logo', 'music_logo.png']);
630
		$content = \file_get_contents($fileName);
631
		return new FileResponse(['content' => $content, 'mimetype' => 'image/png']);
632
	}
633
634
	/**
635
	 * @SubsonicAPI
636
	 */
637
	private function star() {
638
		$targetIds = $this->getStarringParameters();
639
640
		$this->trackBusinessLayer->setStarred($targetIds['tracks'], $this->userId);
0 ignored issues
show
Bug introduced by
It seems like $targetIds['tracks'] can also be of type string[]; however, parameter $ids of OCA\Music\AppFramework\B...nessLayer::setStarred() 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

640
		$this->trackBusinessLayer->setStarred(/** @scrutinizer ignore-type */ $targetIds['tracks'], $this->userId);
Loading history...
641
		$this->albumBusinessLayer->setStarred($targetIds['albums'], $this->userId);
642
		$this->artistBusinessLayer->setStarred($targetIds['artists'], $this->userId);
643
644
		return $this->subsonicResponse([]);
645
	}
646
647
	/**
648
	 * @SubsonicAPI
649
	 */
650
	private function unstar() {
651
		$targetIds = $this->getStarringParameters();
652
653
		$this->trackBusinessLayer->unsetStarred($targetIds['tracks'], $this->userId);
0 ignored issues
show
Bug introduced by
It seems like $targetIds['tracks'] can also be of type string[]; however, parameter $ids of OCA\Music\AppFramework\B...ssLayer::unsetStarred() 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

653
		$this->trackBusinessLayer->unsetStarred(/** @scrutinizer ignore-type */ $targetIds['tracks'], $this->userId);
Loading history...
654
		$this->albumBusinessLayer->unsetStarred($targetIds['albums'], $this->userId);
655
		$this->artistBusinessLayer->unsetStarred($targetIds['artists'], $this->userId);
656
657
		return $this->subsonicResponse([]);
658
	}
659
660
	/**
661
	 * @SubsonicAPI
662
	 */
663
	private function getStarred() {
664
		$starred = $this->doGetStarred();
665
		return $this->searchResponse('starred', $starred, /*$useNewApi=*/false);
666
	}
667
668
	/**
669
	 * @SubsonicAPI
670
	 */
671
	private function getStarred2() {
672
		$starred = $this->doGetStarred();
673
		return $this->searchResponse('starred2', $starred, /*$useNewApi=*/true);
674
	}
675
676
	/**
677
	 * @SubsonicAPI
678
	 */
679
	private function getVideos() {
680
		// Feature not supported, return an empty list
681
		return $this->subsonicResponse([
682
			'videos' => [
683
				'video' => []
684
			]
685
		]);
686
	}
687
688
	/**
689
	 * @SubsonicAPI
690
	 */
691
	 private function getPodcasts() {
692
		// Feature not supported, return an empty list
693
		return $this->subsonicResponse([
694
			'podcasts' => [
695
				'channel' => []
696
			]
697
		]);
698
	}
699
700
	/**
701
	 * @SubsonicAPI
702
	 */
703
	private function getBookmarks() {
704
		$bookmarkNodes = [];
705
		$bookmarks = $this->bookmarkBusinessLayer->findAll($this->userId);
706
707
		foreach ($bookmarks as $bookmark) {
708
			try {
709
				$trackId = $bookmark->getTrackId();
710
				$track = $this->trackBusinessLayer->find($trackId, $this->userId);
711
				$node = $this->bookmarkToApi($bookmark);
712
				$node['entry'] = $this->trackToApi($track);
713
				$bookmarkNodes[] = $node;
714
			}
715
			catch (BusinessLayerException $e) {
0 ignored issues
show
Bug introduced by
The type OCA\Music\Controller\BusinessLayerException was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
716
				$this->logger->log("Bookmarked track $trackId not found", 'warn');
717
			}
718
		}
719
720
		return $this->subsonicResponse(['bookmarks' => ['bookmark' => $bookmarkNodes]]);
721
	}
722
723
	/**
724
	 * @SubsonicAPI
725
	 */
726
	private function createBookmark() {
727
		$this->bookmarkBusinessLayer->addOrUpdate(
728
				$this->userId,
729
				self::ripIdPrefix($this->getRequiredParam('id')),
730
				$this->getRequiredParam('position'),
731
				$this->request->getParam('comment')
732
		);
733
		return $this->subsonicResponse([]);
734
	}
735
736
	/**
737
	 * @SubsonicAPI
738
	 */
739
	private function deleteBookmark() {
740
		$id = $this->getRequiredParam('id');
741
		$trackId = self::ripIdPrefix($id);
742
743
		$bookmark = $this->bookmarkBusinessLayer->findByTrack($trackId, $this->userId);
744
		$this->bookmarkBusinessLayer->delete($bookmark->getId(), $this->userId);
745
746
		return $this->subsonicResponse([]);
747
	}
748
749
	/* -------------------------------------------------------------------------
750
	 * Helper methods
751
	 *------------------------------------------------------------------------*/
752
753
	private function getRequiredParam($paramName) {
754
		$param = $this->request->getParam($paramName);
755
756
		if ($param === null) {
757
			throw new SubsonicException("Required parameter '$paramName' missing", 10);
758
		}
759
760
		return $param;
761
	}
762
763
	/**
764
	 * Get parameters used in the `star` and `unstar` API methods
765
	 */
766
	private function getStarringParameters() {
767
		// album IDs from newer clients
768
		$albumIds = $this->getRepeatedParam('albumId');
769
		$albumIds = \array_map('self::ripIdPrefix', $albumIds);
770
771
		// artist IDs from newer clients
772
		$artistIds = $this->getRepeatedParam('artistId');
773
		$artistIds = \array_map('self::ripIdPrefix', $artistIds);
774
775
		// song IDs from newer clients and song/folder/album/artist IDs from older clients
776
		$ids = $this->getRepeatedParam('id');
777
778
		$trackIds = [];
779
780
		foreach ($ids as $prefixedId) {
781
			$parts = \explode('-', $prefixedId);
782
			$type = $parts[0];
783
			$id = $parts[1];
784
785
			if ($type == 'track') {
786
				$trackIds[] = $id;
787
			} elseif ($type == 'album') {
788
				$albumIds[] = $id;
789
			} elseif ($type == 'artist') {
790
				$artistIds[] = $id;
791
			} elseif ($type == 'folder') {
792
				throw new SubsonicException('Starring folders is not supported', 0);
793
			} else {
794
				throw new SubsonicException("Unexpected ID format: $prefixedId", 0);
795
			}
796
		}
797
798
		return [
799
			'tracks' => $trackIds,
800
			'albums' => $albumIds,
801
			'artists' => $artistIds
802
		];
803
	}
804
805
	/** 
806
	 * Get values for parameter which may be present multiple times in the query
807
	 * string or POST data.
808
	 * @param string $paramName
809
	 * @return string[]
810
	 */
811
	private function getRepeatedParam($paramName) {
812
		// We can't use the IRequest object nor $_GET and $_POST to get the data
813
		// because all of these are based to the idea of unique parameter names.
814
		// If the same name is repeated, only the last value is saved. Hence, we
815
		// need to parse the raw data manually.
816
817
		// query string is always present (although it could be empty)
818
		$values = $this->parseRepeatedKeyValues($paramName, $_SERVER['QUERY_STRING']);
819
820
		// POST data is available if the method is POST
821
		if ($this->request->getMethod() == 'POST') {
822
			$values = \array_merge($values,
823
					$this->parseRepeatedKeyValues($paramName, file_get_contents('php://input')));
824
		}
825
826
		return $values;
827
	}
828
829
	/**
830
	 * Parse a string like "someKey=value1&someKey=value2&anotherKey=valueA&someKey=value3"
831
	 * and return an array of values for the given key
832
	 * @param string $key
833
	 * @param string $data
834
	 */
835
	private function parseRepeatedKeyValues($key, $data) {
836
		$result = [];
837
838
		$keyValuePairs = \explode('&', $data);
839
840
		foreach ($keyValuePairs as $pair) {
841
			$keyAndValue = \explode('=', $pair);
842
843
			if ($keyAndValue[0] == $key) {
844
				$result[] = $keyAndValue[1];
845
			}
846
		}
847
848
		return $result;
849
	}
850
851
	private function getFilesystemNode($id) {
852
		$rootFolder = $this->userMusicFolder->getFolder($this->userId);
853
		$nodes = $rootFolder->getById($id);
854
855
		if (\count($nodes) != 1) {
856
			throw new SubsonicException('file not found', 70);
857
		}
858
859
		return $nodes[0];
860
	}
861
862
	private function getIndexesForFolders() {
863
		$rootFolder = $this->userMusicFolder->getFolder($this->userId);
864
865
		return $this->subsonicResponse(['indexes' => ['index' => [
866
			['name' => '*',
867
			'artist' => [['id' => 'folder-' . $rootFolder->getId(), 'name' => $rootFolder->getName()]]]
868
		]]]);
869
	}
870
871
	private function getMusicDirectoryForFolder($id) {
872
		$folderId = self::ripIdPrefix($id); // get rid of 'folder-' prefix
873
		$folder = $this->getFilesystemNode($folderId);
874
875
		if (!($folder instanceof Folder)) {
876
			throw new SubsonicException("$id is not a valid folder", 70);
877
		}
878
879
		$nodes = $folder->getDirectoryListing();
880
		$subFolders = \array_filter($nodes, function ($n) {
881
			return $n instanceof Folder;
882
		});
883
		$tracks = $this->trackBusinessLayer->findAllByFolder($folderId, $this->userId);
884
885
		// A folder may contain thousands of audio files, and getting album data
886
		// for each of those individually would take a lot of time and great many DB queries.
887
		// To prevent having to do this in `trackToApi`, we fetch all the albums in one go.
888
		$this->injectAlbumsToTracks($tracks);
889
890
		$children = \array_merge(
891
			\array_map([$this, 'folderToApi'], $subFolders),
892
			\array_map([$this, 'trackToApi'], $tracks)
893
		);
894
895
		$content = [
896
			'directory' => [
897
				'id' => $id,
898
				'name' => $folder->getName(),
899
				'child' => $children
900
			]
901
		];
902
903
		// Parent folder ID is included if and only if the parent folder is not the top level
904
		$rootFolderId = $this->userMusicFolder->getFolder($this->userId)->getId();
905
		$parentFolderId = $folder->getParent()->getId();
906
		if ($rootFolderId != $parentFolderId) {
907
			$content['parent'] = 'folder-' . $parentFolderId;
908
		}
909
910
		return $this->subsonicResponse($content);
911
	}
912
913
	private function injectAlbumsToTracks(&$tracks) {
914
		$albumIds = [];
915
916
		// get unique album IDs
917
		foreach ($tracks as $track) {
918
			$albumIds[$track->getAlbumId()] = 1;
919
		}
920
		$albumIds = \array_keys($albumIds);
921
922
		// get the corresponding entities from the business layer
923
		$albums = $this->albumBusinessLayer->findById($albumIds, $this->userId);
924
925
		// create hash tables "id => entity" for the albums for fast access
926
		$albumMap = Util::createIdLookupTable($albums);
927
928
		// finally, set the references on the tracks
929
		foreach ($tracks as &$track) {
930
			$track->setAlbum($albumMap[$track->getAlbumId()]);
931
		}
932
	}
933
934
	private function getIndexesForArtists($rootElementName = 'indexes') {
935
		$artists = $this->artistBusinessLayer->findAllHavingAlbums($this->userId, SortBy::Name);
936
937
		$indexes = [];
938
		foreach ($artists as $artist) {
939
			$indexes[$artist->getIndexingChar()][] = $this->artistToApi($artist);
940
		}
941
942
		$result = [];
943
		foreach ($indexes as $indexChar => $bucketArtists) {
944
			$result[] = ['name' => $indexChar, 'artist' => $bucketArtists];
945
		}
946
947
		return $this->subsonicResponse([$rootElementName => ['index' => $result]]);
948
	}
949
950
	private function getMusicDirectoryForArtist($id) {
951
		$artistId = self::ripIdPrefix($id); // get rid of 'artist-' prefix
952
953
		$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
954
		$albums = $this->albumBusinessLayer->findAllByArtist($artistId, $this->userId);
955
956
		return $this->subsonicResponse([
957
			'directory' => [
958
				'id' => $id,
959
				'name' => $artist->getNameString($this->l10n),
960
				'child' => \array_map([$this, 'albumToOldApi'], $albums)
961
			]
962
		]);
963
	}
964
965
	private function getMusicDirectoryForAlbum($id) {
966
		$albumId = self::ripIdPrefix($id); // get rid of 'album-' prefix
967
968
		$album = $this->albumBusinessLayer->find($albumId, $this->userId);
969
		$albumName = $album->getNameString($this->l10n);
970
		$tracks = $this->trackBusinessLayer->findAllByAlbum($albumId, $this->userId);
971
972
		return $this->subsonicResponse([
973
			'directory' => [
974
				'id' => $id,
975
				'parent' => 'artist-' . $album->getAlbumArtistId(),
976
				'name' => $albumName,
977
				'child' => \array_map(function($track) use ($album) {
978
					$track->setAlbum($album);
979
					return $this->trackToApi($track);
980
				}, $tracks)
981
			]
982
		]);
983
	}
984
985
	/**
986
	 * @param Folder $folder
987
	 * @return array
988
	 */
989
	private function folderToApi($folder) {
990
		return [
991
			'id' => 'folder-' . $folder->getId(),
992
			'title' => $folder->getName(),
993
			'isDir' => true
994
		];
995
	}
996
997
	/**
998
	 * @param Artist $artist
999
	 * @return array
1000
	 */
1001
	private function artistToApi($artist) {
1002
		$id = $artist->getId();
1003
		$result = [
1004
			'name' => $artist->getNameString($this->l10n),
1005
			'id' => $id ? ('artist-' . $id) : '-1', // getArtistInfo may show artists without ID
1006
			'albumCount' => $this->albumBusinessLayer->countByArtist($artist->getId())
1007
		];
1008
1009
		if (!empty($artist->getCoverFileId())) {
1010
			$result['coverArt'] = 'artist-' . $artist->getId();
1011
		}
1012
1013
		if ($artist->getStarred() != null) {
1014
			$result['starred'] = $this->formatDateTime($artist->getStarred());
1015
		}
1016
1017
		return $result;
1018
	}
1019
1020
	/**
1021
	 * The "old API" format is used e.g. in getMusicDirectory and getAlbumList
1022
	 * @param Album $album
1023
	 * @return array
1024
	 */
1025
	private function albumToOldApi($album) {
1026
		$result = $this->albumCommonApiFields($album);
1027
1028
		$result['parent'] = 'artist-' . $album->getAlbumArtistId();
1029
		$result['title'] = $album->getNameString($this->l10n);
1030
		$result['isDir'] = true;
1031
1032
		return $result;
1033
	}
1034
1035
	/**
1036
	 * The "new API" format is used e.g. in getAlbum and getAlbumList2
1037
	 * @param Album $album
1038
	 * @return array
1039
	 */
1040
	private function albumToNewApi($album) {
1041
		$result = $this->albumCommonApiFields($album);
1042
1043
		$result['artistId'] = 'artist-' . $album->getAlbumArtistId();
1044
		$result['name'] = $album->getNameString($this->l10n);
1045
		$result['songCount'] = $this->trackBusinessLayer->countByAlbum($album->getId());
1046
		$result['duration'] = $this->trackBusinessLayer->totalDurationOfAlbum($album->getId());
1047
1048
		return $result;
1049
	}
1050
1051
	private function albumCommonApiFields($album) {
1052
		$result = [
1053
			'id' => 'album-' . $album->getId(),
1054
			'artist' => $album->getAlbumArtistNameString($this->l10n)
1055
		];
1056
1057
		if (!empty($album->getCoverFileId())) {
1058
			$result['coverArt'] = 'album-' . $album->getId();
1059
		}
1060
1061
		if ($album->getStarred() != null) {
1062
			$result['starred'] = $this->formatDateTime($album->getStarred());
1063
		}
1064
1065
		if (!empty($album->getGenres())) {
1066
			$result['genre'] = \implode(', ', \array_map(function($genreId) {
1067
				return $this->genreBusinessLayer->find($genreId, $this->userId)->getNameString($this->l10n);
1068
			}, $album->getGenres()));
1069
		}
1070
1071
		if (!empty($album->getYears())) {
1072
			$result['year'] = $album->yearToAPI();
1073
		}
1074
1075
		return $result;
1076
	}
1077
1078
	/**
1079
	 * The same API format is used both on "old" and "new" API methods. The "new" API adds some
1080
	 * new fields for the songs, but providing some extra fields shouldn't be a problem for the
1081
	 * older clients. 
1082
	 * @param Track $track If the track entity has no album references set, then it is automatically
1083
	 *                     fetched from the AlbumBusinessLayer module.
1084
	 * @return array
1085
	 */
1086
	private function trackToApi($track) {
1087
		$albumId = $track->getAlbumId();
1088
1089
		$album = $track->getAlbum();
1090
		if (empty($album)) {
1091
			$album = $this->albumBusinessLayer->find($albumId, $this->userId);
1092
			$track->setAlbum($album);
1093
		}
1094
1095
		$result = [
1096
			'id' => 'track-' . $track->getId(),
1097
			'parent' => 'album-' . $albumId,
1098
			//'discNumber' => $track->getDisk(), // not supported on any of the tested clients => adjust track number instead
1099
			'title' => $track->getTitle(),
1100
			'artist' => $track->getArtistNameString($this->l10n),
1101
			'isDir' => false,
1102
			'album' => $album->getNameString($this->l10n),
1103
			'year' => $track->getYear(),
1104
			'size' => $track->getSize(),
1105
			'contentType' => $track->getMimetype(),
1106
			'suffix' => $track->getFileExtension(),
1107
			'duration' => $track->getLength() ?: 0,
1108
			'bitRate' => \round($track->getBitrate()/1000) ?: 0, // convert bps to kbps
1109
			//'path' => '',
1110
			'isVideo' => false,
1111
			'albumId' => 'album-' . $albumId,
1112
			'artistId' => 'artist-' . $track->getArtistId(),
1113
			'type' => 'music'
1114
		];
1115
1116
		if (!empty($album->getCoverFileId())) {
1117
			$result['coverArt'] = 'album-' . $album->getId();
1118
		}
1119
1120
		$trackNumber = $track->getAdjustedTrackNumber();
1121
		if ($trackNumber !== null) {
1122
			$result['track'] = $trackNumber;
1123
		}
1124
1125
		if ($track->getStarred() != null) {
1126
			$result['starred'] = $this->formatDateTime($track->getStarred());
1127
		}
1128
1129
		if (!empty($track->getGenreId())) {
1130
			$result['genre'] = $track->getGenreNameString($this->l10n);
1131
		}
1132
1133
		return $result;
1134
	}
1135
1136
	/**
1137
	 * @param Playlist $playlist
1138
	 * @return array
1139
	 */
1140
	private function playlistToApi($playlist) {
1141
		return [
1142
			'id' => $playlist->getId(),
1143
			'name' => $playlist->getName(),
1144
			'owner' => $this->userId,
1145
			'public' => false,
1146
			'songCount' => $playlist->getTrackCount(),
1147
			'duration' => $this->playlistBusinessLayer->getDuration($playlist->getId(), $this->userId),
1148
			'comment' => $playlist->getComment() ?: '',
1149
			'created' => $this->formatDateTime($playlist->getCreated()),
1150
			//'changed' => '' // added and required in API 1.13.0
1151
			//'coverArt' => '' // added in API 1.11.0 but is optional even there
1152
		];
1153
	}
1154
1155
	/**
1156
	 * @param Bookmark $bookmark
1157
	 * @return array
1158
	 */
1159
	private function bookmarkToApi($bookmark) {
1160
		return [
1161
			'position' => $bookmark->getPosition(),
1162
			'username' => $this->userId,
1163
			'comment' => $bookmark->getComment() ?: '',
1164
			'created' => $this->formatDateTime($bookmark->getCreated()),
1165
			'changed' => $this->formatDateTime($bookmark->getUpdated())
1166
		];
1167
	}
1168
1169
	/**
1170
	 * Common logic for getAlbumList and getAlbumList2
1171
	 * @return Album[]
1172
	 */
1173
	private function albumsForGetAlbumList() {
1174
		$type = $this->getRequiredParam('type');
1175
		$size = $this->request->getParam('size', 10);
1176
		$size = \min($size, 500); // the API spec limits the maximum amount to 500
1177
		$offset = $this->request->getParam('offset', 0);
1178
1179
		$albums = [];
1180
1181
		switch ($type) {
1182
			case 'random':
1183
				$allAlbums = $this->albumBusinessLayer->findAll($this->userId);
1184
				$indices = $this->random->getIndices(\count($allAlbums), $offset, $size, $this->userId, 'subsonic_albums');
1185
				$albums = Util::arrayMultiGet($allAlbums, $indices);
1186
				break;
1187
			case 'starred':
1188
				$albums = $this->albumBusinessLayer->findAllStarred($this->userId, $size, $offset);
1189
				break;
1190
			case 'alphabeticalByName':
1191
				$albums = $this->albumBusinessLayer->findAll($this->userId, SortBy::Name, $size, $offset);
1192
				break;
1193
			case 'alphabeticalByArtist':
1194
				$albums = $this->albumBusinessLayer->findAll($this->userId, SortBy::Parent, $size, $offset);
1195
				break;
1196
			case 'byGenre':
1197
				$genre = $this->getRequiredParam('genre');
1198
				$albums = $this->findAlbumsByGenre($genre, $size, $offset);
1199
				break;
1200
			case 'byYear':
1201
				$fromYear = $this->getRequiredParam('fromYear');
1202
				$toYear = $this->getRequiredParam('toYear');
1203
				$albums = $this->albumBusinessLayer->findAllByYearRange($fromYear, $toYear, $this->userId, $size, $offset);
1204
				break;
1205
			case 'newest':
1206
				$albums = $this->albumBusinessLayer->findAll($this->userId, SortBy::Newest, $size, $offset);
1207
				break;
1208
			case 'highest':
1209
			case 'frequent':
1210
			case 'recent':
1211
			default:
1212
				$this->logger->log("Album list type '$type' is not supported", 'debug');
1213
				break;
1214
		}
1215
1216
		return $albums;
1217
	}
1218
1219
	/**
1220
	 * Common logic for getArtistInfo and getArtistInfo2
1221
	 */
1222
	private function doGetArtistInfo($rootName) {
1223
		$content = [];
1224
1225
		$id = $this->getRequiredParam('id');
1226
1227
		// This function may be called with a folder ID instead of an artist ID in case
1228
		// the library is being browsed by folders. In that case, return an empty response.
1229
		if (Util::startsWith($id, 'artist')) {
1230
			$artistId = self::ripIdPrefix($id); // get rid of 'artist-' prefix
1231
			$includeNotPresent = $this->request->getParam('includeNotPresent', false);
1232
			$includeNotPresent = \filter_var($includeNotPresent, FILTER_VALIDATE_BOOLEAN);
1233
1234
			$info = $this->lastfmService->getArtistInfo($artistId, $this->userId);
1235
1236
			if (isset($info['artist'])) {
1237
				$content = [
1238
					'biography' => $info['artist']['bio']['summary'],
1239
					'lastFmUrl' => $info['artist']['url'],
1240
					'musicBrainzId' => $info['artist']['mbid']
1241
				];
1242
1243
				$similarArtists = $this->lastfmService->getSimilarArtists($artistId, $this->userId, $includeNotPresent);
1244
				$content['similarArtist'] = \array_map([$this, 'artistToApi'], $similarArtists);
1245
			}
1246
1247
			$artist = $this->artistBusinessLayer->find($artistId, $this->userId);
1248
			if ($artist->getCoverFileId() !== null) {
1249
				$url = $this->urlGenerator->linkToRouteAbsolute('music.subsonic.handleRequest', ['method' => 'getCoverArt'])
1250
						. "?u={$this->request->u}&p={$this->request->p}&v={$this->request->v}&c={$this->request->c}&id=$id";
1251
				$content['largeImageUrl'] = [$url];
1252
			}
1253
		}
1254
1255
		// This method is unusual in how it uses non-attribute elements in the response. On the other hand,
1256
		// all the details of the <similarArtist> elements are rendered as attributes. List those separately.
1257
		$attributeKeys = ['name', 'id', 'albumCount', 'coverArt', 'starred'];
1258
1259
		return $this->subsonicResponse([$rootName => $content], $attributeKeys);
1260
	}
1261
1262
	/**
1263
	 * Common logic for getSimilarSongs and getSimilarSongs2
1264
	 */
1265
	private function doGetSimilarSongs($rootName) {
1266
		$id = $this->getRequiredParam('id');
1267
		$count = $this->request->getParam('count', 50);
1268
1269
		if (Util::startsWith($id, 'artist')) {
1270
			$artistId = self::ripIdPrefix($id);
1271
		} elseif (Util::startsWith($id, 'album')) {
1272
			$albumId = self::ripIdPrefix($id);
1273
			$artistId = $this->albumBusinessLayer->find($albumId, $this->userId)->getAlbumArtistId();
1274
		} elseif (Util::startsWith($id, 'track')) {
1275
			$trackId = self::ripIdPrefix($id);
1276
			$artistId = $this->trackBusinessLayer->find($trackId, $this->userId)->getArtistId();
1277
		} else {
1278
			throw new SubsonicException("Id $id has a type not supported on getSimilarSongs", 0);
1279
		}
1280
1281
		$artists = $this->lastfmService->getSimilarArtists($artistId, $this->userId);
1282
		$artists[] = $this->artistBusinessLayer->find($artistId, $this->userId);
1283
1284
		// Get all songs by the found artists
1285
		$songs = [];
1286
		foreach ($artists as $artist) {
1287
			$songs = \array_merge($songs, $this->trackBusinessLayer->findAllByArtist($artist->getId(), $this->userId));
1288
		}
1289
1290
		// Randomly select the desired number of songs
1291
		$songs = $this->random->pickItems($songs, $count);
1292
1293
		return $this->subsonicResponse([$rootName => [
1294
				'song' => \array_map([$this, 'trackToApi'], $songs)
1295
		]]);
1296
	}
1297
1298
	/**
1299
	 * Common logic for search2 and search3
1300
	 * @return array with keys 'artists', 'albums', and 'tracks'
1301
	 */
1302
	private function doSearch() {
1303
		$query = $this->getRequiredParam('query');
1304
		$artistCount = $this->request->getParam('artistCount', 20);
1305
		$artistOffset = $this->request->getParam('artistOffset', 0);
1306
		$albumCount = $this->request->getParam('albumCount', 20);
1307
		$albumOffset = $this->request->getParam('albumOffset', 0);
1308
		$songCount = $this->request->getParam('songCount', 20);
1309
		$songOffset = $this->request->getParam('songOffset', 0);
1310
1311
		if (empty($query)) {
1312
			throw new SubsonicException("The 'query' argument is mandatory", 10);
1313
		}
1314
1315
		return [
1316
			'artists' => $this->artistBusinessLayer->findAllByName($query, $this->userId, true, $artistCount, $artistOffset),
1317
			'albums' => $this->albumBusinessLayer->findAllByName($query, $this->userId, true, $albumCount, $albumOffset),
1318
			'tracks' => $this->trackBusinessLayer->findAllByName($query, $this->userId, true, $songCount, $songOffset)
1319
		];
1320
	}
1321
1322
	/**
1323
	 * Common logic for getStarred and getStarred2
1324
	 * @return array
1325
	 */
1326
	private function doGetStarred() {
1327
		return [
1328
			'artists' => $this->artistBusinessLayer->findAllStarred($this->userId),
1329
			'albums' => $this->albumBusinessLayer->findAllStarred($this->userId),
1330
			'tracks' => $this->trackBusinessLayer->findAllStarred($this->userId)
1331
		];
1332
	}
1333
1334
	/**
1335
	 * Common response building logic for search2, search3, getStarred, and getStarred2
1336
	 * @param string $title Name of the main node in the response message
1337
	 * @param array $results Search results with keys 'artists', 'albums', and 'tracks'
1338
	 * @param boolean $useNewApi Set to true for search3 and getStarred2. There is a difference
1339
	 *                           in the formatting of the album nodes.
1340
	 * @return \OCP\AppFramework\Http\Response
0 ignored issues
show
Bug introduced by
The type OCP\AppFramework\Http\Response was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
1341
	 */
1342
	private function searchResponse($title, $results, $useNewApi) {
1343
		$albumMapFunc = $useNewApi ? 'albumToNewApi' : 'albumToOldApi';
1344
1345
		return $this->subsonicResponse([$title => [
1346
			'artist' => \array_map([$this, 'artistToApi'], $results['artists']),
1347
			'album' => \array_map([$this, $albumMapFunc], $results['albums']),
1348
			'song' => \array_map([$this, 'trackToApi'], $results['tracks'])
1349
		]]);
1350
	}
1351
1352
	/**
1353
	 * Find tracks by genre name
1354
	 * @param string $genreName
1355
	 * @param int|null $limit
1356
	 * @param int|null $offset
1357
	 * @return Track[]
1358
	 */
1359
	private function findTracksByGenre($genreName, $limit=null, $offset=null) {
1360
		$genre = $this->findGenreByName($genreName);
1361
1362
		if ($genre) {
1363
			return $this->trackBusinessLayer->findAllByGenre($genre->getId(), $this->userId, $limit, $offset);
1364
		} else {
1365
			return [];
1366
		}
1367
	}
1368
1369
	/**
1370
	 * Find albums by genre name
1371
	 * @param string $genreName
1372
	 * @param int|null $limit
1373
	 * @param int|null $offset
1374
	 * @return Album[]
1375
	 */
1376
	private function findAlbumsByGenre($genreName, $limit=null, $offset=null) {
1377
		$genre = $this->findGenreByName($genreName);
1378
1379
		if ($genre) {
1380
			return $this->albumBusinessLayer->findAllByGenre($genre->getId(), $this->userId, $limit, $offset);
1381
		} else {
1382
			return [];
1383
		}
1384
	}
1385
1386
	private function findGenreByName($name) {
1387
		$genreArr = $this->genreBusinessLayer->findAllByName($name, $this->userId);
1388
		if (\count($genreArr) == 0 && $name == Genre::unknownNameString($this->l10n)) {
1389
			$genreArr = $this->genreBusinessLayer->findAllByName('', $this->userId);
1390
		}
1391
		return \count($genreArr) ? $genreArr[0] : null;
1392
	}
1393
1394
	/**
1395
	 * Given a prefixed ID like 'artist-123' or 'track-45', return just the numeric part.
1396
	 * @param string $id
1397
	 * @return integer
1398
	 */
1399
	private static function ripIdPrefix($id) {
1400
		return (int)(\explode('-', $id)[1]);
1401
	}
1402
1403
	private function formatDateTime($dateString) {
1404
		$dateTime = new \DateTime($dateString);
1405
		$dateTime->setTimezone($this->timezone);
1406
		return $dateTime->format('Y-m-d\TH:i:s');
1407
	}
1408
1409
	private function subsonicResponse($content, $useAttributes=true, $status = 'ok') {
1410
		$content['status'] = $status; 
1411
		$content['version'] = self::API_VERSION;
1412
		$responseData = ['subsonic-response' => $content];
1413
1414
		if ($this->format == 'json') {
1415
			$response = new JSONResponse($responseData);
1416
		} else if ($this->format == 'jsonp') {
1417
			$responseData = \json_encode($responseData);
1418
			$response = new DataDisplayResponse("{$this->callback}($responseData);");
1419
			$response->addHeader('Content-Type', 'text/javascript; charset=UTF-8');
1420
		} else {
1421
			if (\is_array($useAttributes)) {
1422
				$useAttributes = \array_merge($useAttributes, ['status', 'version']);
1423
			}
1424
			$response = new XMLResponse($responseData, $useAttributes);
1425
		}
1426
1427
		return $response;
1428
	}
1429
1430
	public function subsonicErrorResponse($errorCode, $errorMessage) {
1431
		return $this->subsonicResponse([
1432
				'error' => [
1433
					'code' => $errorCode,
1434
					'message' => $errorMessage
1435
				]
1436
			], true, 'failed');
1437
	}
1438
}
1439