MusicApiController::download()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 10
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 2
eloc 7
c 1
b 0
f 0
nc 2
nop 1
dl 0
loc 10
rs 10
1
<?php declare(strict_types=1);
2
3
/**
4
 * ownCloud - Music app
5
 *
6
 * This file is licensed under the Affero General Public License version 3 or
7
 * later. See the COPYING file.
8
 *
9
 * @author Morris Jobke <[email protected]>
10
 * @author Pauli Järvinen <[email protected]>
11
 * @copyright Morris Jobke 2013, 2014
12
 * @copyright Pauli Järvinen 2017 - 2026
13
 */
14
15
namespace OCA\Music\Controller;
16
17
use OCA\Music\AppFramework\BusinessLayer\BusinessLayerException;
18
use OCA\Music\AppFramework\Core\Logger;
19
use OCA\Music\BusinessLayer\GenreBusinessLayer;
20
use OCA\Music\BusinessLayer\TrackBusinessLayer;
21
use OCA\Music\Db\Maintenance;
22
use OCA\Music\Http\ErrorResponse;
23
use OCA\Music\Http\FileStreamResponse;
24
use OCA\Music\Service\CollectionService;
25
use OCA\Music\Service\CoverService;
26
use OCA\Music\Service\DetailsService;
27
use OCA\Music\Service\FileSystemService;
28
use OCA\Music\Service\LastfmService;
29
use OCA\Music\Service\LibrarySettings;
30
use OCA\Music\Service\Scanner;
31
use OCA\Music\Service\Scrobbler;
32
use OCA\Music\Utility\HttpUtil;
33
use OCA\Music\Utility\Util;
34
35
use OCP\AppFramework\Controller;
36
use OCP\AppFramework\Http;
37
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
38
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
39
use OCP\AppFramework\Http\Attribute\UseSession;
40
use OCP\AppFramework\Http\DataDisplayResponse;
41
use OCP\AppFramework\Http\JSONResponse;
42
use OCP\AppFramework\Http\Response;
43
use OCP\IRequest;
44
45
class MusicApiController extends Controller {
46
47
	private TrackBusinessLayer $trackBusinessLayer;
48
	private GenreBusinessLayer $genreBusinessLayer;
49
	private Scanner $scanner;
50
	private CollectionService $collectionService;
51
	private CoverService $coverService;
52
	private DetailsService $detailsService;
53
	private FileSystemService $fileSystemService;
54
	private LastfmService $lastfmService;
55
	private Maintenance $maintenance;
56
	private LibrarySettings $librarySettings;
57
	private string $userId;
58
	private Logger $logger;
59
	private Scrobbler $scrobbler;
60
61
	public function __construct(
62
			string $appName,
63
			IRequest $request,
64
			TrackBusinessLayer $trackBusinessLayer,
65
			GenreBusinessLayer $genreBusinessLayer,
66
			Scanner $scanner,
67
			CollectionService $collectionService,
68
			CoverService $coverService,
69
			DetailsService $detailsService,
70
			FileSystemService $fileSystemService,
71
			LastfmService $lastfmService,
72
			Maintenance $maintenance,
73
			LibrarySettings $librarySettings,
74
			?string $userId,
75
			Logger $logger,
76
			Scrobbler $scrobbler
77
	) {
78
		parent::__construct($appName, $request);
79
		$this->trackBusinessLayer = $trackBusinessLayer;
80
		$this->genreBusinessLayer = $genreBusinessLayer;
81
		$this->scanner = $scanner;
82
		$this->collectionService = $collectionService;
83
		$this->coverService = $coverService;
84
		$this->detailsService = $detailsService;
85
		$this->fileSystemService = $fileSystemService;
86
		$this->lastfmService = $lastfmService;
87
		$this->maintenance = $maintenance;
88
		$this->librarySettings = $librarySettings;
89
		$this->userId = $userId ?? ''; // null case should happen only when the user has already logged out
90
		$this->logger = $logger;
91
		$this->scrobbler = $scrobbler;
92
	}
93
94
	#[NoAdminRequired]
95
	#[NoCSRFRequired]
96
	public function prepareCollection() : JSONResponse {
97
		$hash = $this->collectionService->getCachedJsonHash();
98
		if ($hash === null) {
99
			// build the collection but ignore the data for now
100
			$this->collectionService->getJson();
101
			$hash = $this->collectionService->getCachedJsonHash();
102
		}
103
		$coverToken = $this->coverService->createAccessToken($this->userId);
104
105
		return new JSONResponse([
106
			'hash' => $hash,
107
			'cover_token' => $coverToken,
108
			'ignored_articles' => $this->librarySettings->getIgnoredArticles($this->userId)
109
		]);
110
	}
111
112
	#[NoAdminRequired]
113
	#[NoCSRFRequired]
114
	public function collection() : DataDisplayResponse {
115
		$collectionJson = $this->collectionService->getJson();
116
		$response = new DataDisplayResponse($collectionJson);
117
		$response->addHeader('Content-Type', 'application/json; charset=utf-8');
118
119
		// Instruct the client to cache the result in case it requested the collection with
120
		// the correct hash. The hash could be incorrect if the collection would have changed
121
		// between calls to prepareCollection() and collection().
122
		$requestHash = $this->request->getParam('hash');
123
		$actualHash = $this->collectionService->getCachedJsonHash();
124
		if (!empty($actualHash) && $requestHash === $actualHash) {
125
			HttpUtil::setClientCachingDays($response, 90);
126
		}
127
128
		return $response;
129
	}
130
131
	#[NoAdminRequired]
132
	#[NoCSRFRequired]
133
	public function folders() : JSONResponse {
134
		$musicFolder = $this->librarySettings->getFolder($this->userId);
135
		$folders = $this->fileSystemService->findAllFolders($this->userId, $musicFolder);
136
		return new JSONResponse($folders);
137
	}
138
139
	#[NoAdminRequired]
140
	#[NoCSRFRequired]
141
	public function genres() : JSONResponse {
142
		$genres = $this->genreBusinessLayer->findAllWithTrackIds($this->userId);
143
		$unscanned =  $this->trackBusinessLayer->findFilesWithoutScannedGenre($this->userId);
144
		return new JSONResponse([
145
			'genres' => \array_map(fn($g) => $g->toApi(), $genres),
146
			'unscanned' => $unscanned
147
		]);
148
	}
149
150
	#[NoAdminRequired]
151
	#[NoCSRFRequired]
152
	public function trackByFileId(int $fileId) : JSONResponse {
153
		$track = $this->trackBusinessLayer->findByFileId($fileId, $this->userId);
154
		if ($track !== null) {
155
			return new JSONResponse($track->toCollection());
156
		} else {
157
			return new ErrorResponse(Http::STATUS_NOT_FOUND);
158
		}
159
	}
160
161
	#[NoAdminRequired]
162
	#[NoCSRFRequired]
163
	public function getScanState() : JSONResponse {
164
		return new JSONResponse($this->scanner->getStatusOfLibraryFiles($this->userId));
165
	}
166
167
	#[NoAdminRequired]
168
	#[NoCSRFRequired]
169
	#[UseSession] // to keep the session reserved while execution in progress
170
	public function scan(string $files, string|int|bool|null $finalize) : JSONResponse {
171
		// extract the parameters
172
		$fileIds = \array_map('intval', \explode(',', $files));
173
		$finalize = \filter_var($finalize, FILTER_VALIDATE_BOOLEAN);
174
175
		list('count' => $filesScanned) = $this->scanner->scanFiles($this->userId, $fileIds);
176
177
		$albumCoversUpdated = false;
178
		if ($finalize) {
179
			$albumCoversUpdated = $this->scanner->findAlbumCovers($this->userId);
180
			$this->scanner->findArtistCovers($this->userId);
181
			$totalCount = $this->trackBusinessLayer->count($this->userId);
182
			$this->logger->info("Scanning finished, user $this->userId has $totalCount scanned tracks in total");
183
		}
184
185
		return new JSONResponse([
186
			'filesScanned' => $filesScanned,
187
			'albumCoversUpdated' => $albumCoversUpdated
188
		]);
189
	}
190
191
	#[NoAdminRequired]
192
	#[NoCSRFRequired]
193
	#[UseSession] // to keep the session reserved while execution in progress
194
	public function removeScanned(string $files) : JSONResponse {
195
		$fileIds = \array_map('intval', \explode(',', $files));
196
		$anythingRemoved = $this->scanner->deleteAudio($fileIds, [$this->userId]);
197
		return new JSONResponse(['filesRemoved' => $anythingRemoved]);
198
	}
199
200
	#[NoAdminRequired]
201
	#[NoCSRFRequired]
202
	#[UseSession] // to keep the session reserved while execution in progress
203
	public function resetScanned() : JSONResponse {
204
		$this->maintenance->resetLibrary($this->userId);
205
		return new JSONResponse(['success' => true]);
206
	}
207
208
	#[NoAdminRequired]
209
	#[NoCSRFRequired]
210
	public function download(int $fileId) : Response {
211
		$nodes = $this->scanner->resolveUserFolder($this->userId)->getById($fileId);
212
		$node = $nodes[0] ?? null;
213
		if ($node instanceof \OCP\Files\File) {
214
			return new FileStreamResponse($node);
215
		}
216
217
		return new ErrorResponse(Http::STATUS_NOT_FOUND, 'file not found');
218
	}
219
220
	#[NoAdminRequired]
221
	#[NoCSRFRequired]
222
	public function filePath(int $fileId) : JSONResponse {
223
		$userFolder = $this->scanner->resolveUserFolder($this->userId);
224
		$nodes = $userFolder->getById($fileId);
225
		if (\count($nodes) == 0) {
226
			return new ErrorResponse(Http::STATUS_NOT_FOUND);
227
		} else {
228
			$node = $nodes[0];
229
			$path = $userFolder->getRelativePath($node->getPath());
230
			return new JSONResponse(['path' => Util::urlEncodePath($path)]);
0 ignored issues
show
Bug introduced by
It seems like $path can also be of type null; however, parameter $path of OCA\Music\Utility\Util::urlEncodePath() does only seem to accept string, 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

230
			return new JSONResponse(['path' => Util::urlEncodePath(/** @scrutinizer ignore-type */ $path)]);
Loading history...
231
		}
232
	}
233
234
	#[NoAdminRequired]
235
	#[NoCSRFRequired]
236
	public function fileInfo(int $fileId) : JSONResponse {
237
		$userFolder = $this->scanner->resolveUserFolder($this->userId);
238
		$info = $this->scanner->getFileInfo($fileId, $this->userId, $userFolder);
239
		if ($info) {
240
			return new JSONResponse($info);
241
		} else {
242
			return new ErrorResponse(Http::STATUS_NOT_FOUND);
243
		}
244
	}
245
246
	#[NoAdminRequired]
247
	#[NoCSRFRequired]
248
	public function fileDetails(int $fileId) : JSONResponse {
249
		$userFolder = $this->scanner->resolveUserFolder($this->userId);
250
		$details = $this->detailsService->getDetails($fileId, $userFolder);
251
		if ($details) {
252
			// metadata extracted, attempt to include also the data from Last.fm
253
			$track = $this->trackBusinessLayer->findByFileId($fileId, $this->userId);
254
			if ($track) {
255
				$details['lastfm'] = $this->lastfmService->getTrackInfo($track->getId(), $this->userId);
256
			} else {
257
				$this->logger->warning("Track with file ID $fileId was not found => can't fetch info from Last.fm");
258
			}
259
260
			return new JSONResponse($details);
261
		} else {
262
			return new ErrorResponse(Http::STATUS_NOT_FOUND);
263
		}
264
	}
265
266
	#[NoAdminRequired]
267
	#[NoCSRFRequired]
268
	public function findDetails(?string $song, ?string $artist) : JSONResponse {
269
		if (empty($song) || empty($artist)) {
270
			return new ErrorResponse(Http::STATUS_BAD_REQUEST, 'Song or artist name argument missing');
271
		} else {
272
			return new JSONResponse(['lastfm' => $this->lastfmService->findTrackInfo($song, $artist)]);
273
		}
274
	}
275
276
	#[NoAdminRequired]
277
	#[NoCSRFRequired]
278
	public function fileLyrics(int $fileId, ?string $format) : Response {
279
		$userFolder = $this->scanner->resolveUserFolder($this->userId);
280
		if ($format == 'plaintext') {
281
			$lyrics = $this->detailsService->getLyricsAsPlainText($fileId, $userFolder);
282
			if (!empty($lyrics)) {
283
				return new DataDisplayResponse($lyrics, Http::STATUS_OK, ['Content-Type' => 'text/plain; charset=utf-8']);
284
			}
285
		} else {
286
			$lyrics = $this->detailsService->getLyricsAsStructured($fileId, $userFolder);
287
			if (!empty($lyrics)) {
288
				return new JSONResponse($lyrics);
289
			}
290
		}
291
		return new ErrorResponse(Http::STATUS_NOT_FOUND);
292
	}
293
294
	#[NoAdminRequired]
295
	#[NoCSRFRequired]
296
	public function scrobble(int $trackId) : JSONResponse {
297
		try {
298
			$this->scrobbler->recordTrackPlayed($trackId, $this->userId);
299
			return new JSONResponse(['success' => true]);
300
		} catch (BusinessLayerException $e) {
301
			return new ErrorResponse(Http::STATUS_NOT_FOUND);
302
		}
303
	}
304
305
	#[NoAdminRequired]
306
	#[NoCSRFRequired]
307
	public function albumDetails(int $albumId, string|int|bool|null $embedCoverArt=false) : JSONResponse {
308
		$embedCoverArt = \filter_var($embedCoverArt, FILTER_VALIDATE_BOOLEAN);
309
		try {
310
			$info = $this->lastfmService->getAlbumInfo($albumId, $this->userId);
311
			if ($embedCoverArt && isset($info['album']['image'])) {
312
				$lastImage = \end($info['album']['image']);
313
				$imageSrc = $lastImage['#text'] ?? null;
314
				if (\is_string($imageSrc)) {
315
					$image = HttpUtil::loadFromUrl($imageSrc);
316
					if ($image['content']) {
317
						$info['album']['imageData'] = 'data:' . $image['content_type'] . ';base64,' . \base64_encode($image['content']);
318
					}
319
				}
320
			}
321
			return new JSONResponse($info);
322
		} catch (BusinessLayerException $e) {
323
			return new ErrorResponse(Http::STATUS_NOT_FOUND);
324
		}
325
	}
326
327
	#[NoAdminRequired]
328
	#[NoCSRFRequired]
329
	public function artistDetails(int $artistId) : JSONResponse {
330
		try {
331
			$info = $this->lastfmService->getArtistInfo($artistId, $this->userId);
332
			return new JSONResponse($info);
333
		} catch (BusinessLayerException $e) {
334
			return new ErrorResponse(Http::STATUS_NOT_FOUND);
335
		}
336
	}
337
338
	#[NoAdminRequired]
339
	#[NoCSRFRequired]
340
	public function similarArtists(int $artistId) : JSONResponse {
341
		try {
342
			$similar = $this->lastfmService->getSimilarArtists($artistId, $this->userId, /*includeNotPresent=*/true);
343
			return new JSONResponse(\array_map(fn($artist) => [
344
				'id' => $artist->getId(),
345
				'name' => $artist->getName(),
346
				'url' => $artist->getLastfmUrl()
347
			], $similar));
348
		} catch (BusinessLayerException $e) {
349
			return new ErrorResponse(Http::STATUS_NOT_FOUND);
350
		}
351
	}
352
}
353