Passed
Push — feature/playlist_improvements ( 2a690a...faf9ee )
by Pauli
14:31
created

SubsonicController::injectAlbumsToTracks()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 18
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 12

Importance

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

603
		$this->trackBusinessLayer->setStarred(/** @scrutinizer ignore-type */ $targetIds['tracks'], $this->userId);
Loading history...
604
		$this->albumBusinessLayer->setStarred($targetIds['albums'], $this->userId);
605
		$this->artistBusinessLayer->setStarred($targetIds['artists'], $this->userId);
606
607
		return $this->subsonicResponse([]);
608
	}
609
610
	/**
611
	 * @SubsonicAPI
612
	 */
613
	private function unstar() {
614
		$targetIds = $this->getStarringParameters();
615
616
		$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

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