MusicApiController::fileDetails()   A
last analyzed

Complexity

Conditions 3
Paths 3

Size

Total Lines 17
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 3
eloc 13
c 1
b 0
f 0
nc 3
nop 1
dl 0
loc 17
rs 9.8333
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
	/**
168
	 * @param string|int|bool|null $finalize
169
	 */
170
	#[NoAdminRequired]
171
	#[NoCSRFRequired]
172
	#[UseSession] // to keep the session reserved while execution in progress
173
	public function scan(string $files, /*mixed*/ $finalize) : JSONResponse {
174
		// extract the parameters
175
		$fileIds = \array_map('intval', \explode(',', $files));
176
		$finalize = \filter_var($finalize, FILTER_VALIDATE_BOOLEAN);
177
178
		list('count' => $filesScanned) = $this->scanner->scanFiles($this->userId, $fileIds);
179
180
		$albumCoversUpdated = false;
181
		if ($finalize) {
182
			$albumCoversUpdated = $this->scanner->findAlbumCovers($this->userId);
183
			$this->scanner->findArtistCovers($this->userId);
184
			$totalCount = $this->trackBusinessLayer->count($this->userId);
185
			$this->logger->info("Scanning finished, user $this->userId has $totalCount scanned tracks in total");
186
		}
187
188
		return new JSONResponse([
189
			'filesScanned' => $filesScanned,
190
			'albumCoversUpdated' => $albumCoversUpdated
191
		]);
192
	}
193
194
	#[NoAdminRequired]
195
	#[NoCSRFRequired]
196
	#[UseSession] // to keep the session reserved while execution in progress
197
	public function removeScanned(string $files) : JSONResponse {
198
		$fileIds = \array_map('intval', \explode(',', $files));
199
		$anythingRemoved = $this->scanner->deleteAudio($fileIds, [$this->userId]);
200
		return new JSONResponse(['filesRemoved' => $anythingRemoved]);
201
	}
202
203
	#[NoAdminRequired]
204
	#[NoCSRFRequired]
205
	#[UseSession] // to keep the session reserved while execution in progress
206
	public function resetScanned() : JSONResponse {
207
		$this->maintenance->resetLibrary($this->userId);
208
		return new JSONResponse(['success' => true]);
209
	}
210
211
	#[NoAdminRequired]
212
	#[NoCSRFRequired]
213
	public function download(int $fileId) : Response {
214
		$nodes = $this->scanner->resolveUserFolder($this->userId)->getById($fileId);
215
		$node = $nodes[0] ?? null;
216
		if ($node instanceof \OCP\Files\File) {
217
			return new FileStreamResponse($node);
218
		}
219
220
		return new ErrorResponse(Http::STATUS_NOT_FOUND, 'file not found');
221
	}
222
223
	#[NoAdminRequired]
224
	#[NoCSRFRequired]
225
	public function filePath(int $fileId) : JSONResponse {
226
		$userFolder = $this->scanner->resolveUserFolder($this->userId);
227
		$nodes = $userFolder->getById($fileId);
228
		if (\count($nodes) == 0) {
229
			return new ErrorResponse(Http::STATUS_NOT_FOUND);
230
		} else {
231
			$node = $nodes[0];
232
			$path = $userFolder->getRelativePath($node->getPath());
233
			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

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