Passed
Push — master ( 7fe04d...543ba8 )
by Pauli
04:49
created

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

635
		$this->trackBusinessLayer->setStarred(/** @scrutinizer ignore-type */ $targetIds['tracks'], $this->userId);
Loading history...
636
		$this->albumBusinessLayer->setStarred($targetIds['albums'], $this->userId);
637
		$this->artistBusinessLayer->setStarred($targetIds['artists'], $this->userId);
638
639
		return $this->subsonicResponse([]);
640
	}
641
642
	/**
643
	 * @SubsonicAPI
644
	 */
645
	private function unstar() {
646
		$targetIds = $this->getStarringParameters();
647
648
		$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

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