Passed
Push — master ( 78428d...ffd534 )
by Pauli
03:51
created

RadioApiController::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 21
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 1
eloc 10
nc 1
nop 11
dl 0
loc 21
rs 9.9332
c 2
b 0
f 0

How to fix   Many Parameters   

Many Parameters

Methods with many parameters are not only hard to understand, but their parameters also often become inconsistent when you need more, or different data.

There are several approaches to avoid long parameter lists:

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 Pauli Järvinen <[email protected]>
10
 * @copyright Pauli Järvinen 2020 - 2025
11
 */
12
13
namespace OCA\Music\Controller;
14
15
use OCP\AppFramework\Controller;
16
use OCP\AppFramework\Http;
17
use OCP\AppFramework\Http\JSONResponse;
18
use OCP\AppFramework\Http\RedirectResponse;
19
use OCP\AppFramework\Http\Response;
20
21
use OCP\Files\Folder;
22
use OCP\Files\IRootFolder;
23
use OCP\IConfig;
24
use OCP\IRequest;
25
use OCP\IURLGenerator;
26
27
use OCA\Music\AppFramework\BusinessLayer\BusinessLayerException;
28
use OCA\Music\AppFramework\Core\Logger;
29
use OCA\Music\AppFramework\Utility\FileExistsException;
30
use OCA\Music\BusinessLayer\RadioStationBusinessLayer;
31
use OCA\Music\Http\ErrorResponse;
32
use OCA\Music\Http\FileResponse;
33
use OCA\Music\Http\RelayStreamResponse;
34
use OCA\Music\Service\PlaylistFileService;
35
use OCA\Music\Service\RadioService;
36
use OCA\Music\Service\StreamTokenService;
37
use OCA\Music\Utility\HttpUtil;
38
39
class RadioApiController extends Controller {
40
	private IConfig $config;
41
	private IURLGenerator $urlGenerator;
42
	private RadioStationBusinessLayer $businessLayer;
43
	private RadioService $service;
44
	private StreamTokenService $tokenService;
45
	private PlaylistFileService $playlistFileService;
46
	private string $userId;
47
	private IRootFolder $rootFolder;
48
	private Logger $logger;
49
50
	public function __construct(string $appName,
51
								IRequest $request,
52
								IConfig $config,
53
								IURLGenerator $urlGenerator,
54
								RadioStationBusinessLayer $businessLayer,
55
								RadioService $service,
56
								StreamTokenService $tokenService,
57
								PlaylistFileService $playlistFileService,
58
								?string $userId,
59
								IRootFolder $rootFolder,
60
								Logger $logger) {
61
		parent::__construct($appName, $request);
62
		$this->config = $config;
63
		$this->urlGenerator = $urlGenerator;
64
		$this->businessLayer = $businessLayer;
65
		$this->service = $service;
66
		$this->tokenService = $tokenService;
67
		$this->playlistFileService = $playlistFileService;
68
		$this->userId = $userId ?? ''; // ensure non-null to satisfy Scrutinizer; may be null when resolveStreamUrl used on public share
69
		$this->rootFolder = $rootFolder;
70
		$this->logger = $logger;
71
	}
72
73
	/**
74
	 * lists all radio stations
75
	 *
76
	 * @NoAdminRequired
77
	 * @NoCSRFRequired
78
	 */
79
	public function getAll() : JSONResponse {
80
		$stations = $this->businessLayer->findAll($this->userId);
81
		return new JSONResponse(
82
			\array_map(fn($s) => $s->toApi(), $stations)
83
		);
84
	}
85
86
	/**
87
	 * creates a station
88
	 *
89
	 * @NoAdminRequired
90
	 * @NoCSRFRequired
91
	 */
92
	public function create(?string $name, ?string $streamUrl, ?string $homeUrl) : JSONResponse {
93
		if ($streamUrl === null) {
94
			return new ErrorResponse(Http::STATUS_BAD_REQUEST, "Mandatory argument 'streamUrl' not given");
95
		}
96
		
97
		try {
98
			$station = $this->businessLayer->create($this->userId, $name, $streamUrl, $homeUrl);
99
			return new JSONResponse($station->toApi());
100
		} catch (\DomainException $ex) {
101
			return new ErrorResponse(Http::STATUS_BAD_REQUEST, $ex->getMessage());
102
		}
103
	}
104
105
	/**
106
	 * deletes a station
107
	 *
108
	 * @NoAdminRequired
109
	 * @NoCSRFRequired
110
	 */
111
	public function delete(int $id) : JSONResponse {
112
		try {
113
			$this->businessLayer->delete($id, $this->userId);
114
			return new JSONResponse([]);
115
		} catch (BusinessLayerException $ex) {
116
			return new ErrorResponse(Http::STATUS_NOT_FOUND, $ex->getMessage());
117
		}
118
	}
119
120
	/**
121
	 * get a single radio station
122
	 *
123
	 * @NoAdminRequired
124
	 * @NoCSRFRequired
125
	 */
126
	public function get(int $id) : JSONResponse {
127
		try {
128
			$station = $this->businessLayer->find($id, $this->userId);
129
			return new JSONResponse($station->toApi());
130
		} catch (BusinessLayerException $ex) {
131
			return new ErrorResponse(Http::STATUS_NOT_FOUND, $ex->getMessage());
132
		}
133
	}
134
135
	/**
136
	 * update a station
137
	 *
138
	 * @NoAdminRequired
139
	 * @NoCSRFRequired
140
	 */
141
	public function update(int $id, ?string $name = null, ?string $streamUrl = null, ?string $homeUrl = null) : JSONResponse {
142
		if ($name === null && $streamUrl === null && $homeUrl === null) {
143
			return new ErrorResponse(Http::STATUS_BAD_REQUEST, "at least one of the args ['name', 'streamUrl', 'homeUrl'] must be given");
144
		}
145
146
		try {
147
			$station = $this->businessLayer->updateStation($id, $this->userId, $name, $streamUrl, $homeUrl);
148
			return new JSONResponse($station->toApi());
149
		} catch (BusinessLayerException $ex) {
150
			return new ErrorResponse(Http::STATUS_NOT_FOUND, $ex->getMessage());
151
		} catch (\DomainException $ex) {
152
			return new ErrorResponse(Http::STATUS_BAD_REQUEST, $ex->getMessage());
153
		}
154
	}
155
156
	/**
157
	 * export all radio stations to a file
158
	 *
159
	 * @param string $name target file name
160
	 * @param string $path parent folder path
161
	 * @param string $oncollision action to take on file name collision,
162
	 *								supported values:
163
	 *								- 'overwrite' The existing file will be overwritten
164
	 *								- 'keepboth' The new file is named with a suffix to make it unique
165
	 *								- 'abort' (default) The operation will fail
166
	 *
167
	 * @NoAdminRequired
168
	 * @NoCSRFRequired
169
	 */
170
	public function exportAllToFile(string $name, string $path, string $oncollision='abort') : JSONResponse {
171
		try {
172
			$userFolder = $this->rootFolder->getUserFolder($this->userId);
173
			$exportedFilePath = $this->playlistFileService->exportRadioStationsToFile(
174
					$this->userId, $userFolder, $path, $name, $oncollision);
175
			return new JSONResponse(['wrote_to_file' => $exportedFilePath]);
176
		} catch (\OCP\Files\NotFoundException $ex) {
177
			return new ErrorResponse(Http::STATUS_NOT_FOUND, 'folder not found');
178
		} catch (FileExistsException $ex) {
179
			return new ErrorResponse(Http::STATUS_CONFLICT, 'file already exists', ['path' => $ex->getPath(), 'suggested_name' => $ex->getAltName()]);
180
		} catch (\OCP\Files\NotPermittedException $ex) {
181
			return new ErrorResponse(Http::STATUS_FORBIDDEN, 'user is not allowed to write to the target file');
182
		}
183
	}
184
185
	/**
186
	 * import radio stations from a file
187
	 * @param string $filePath path of the file to import
188
	 *
189
	 * @NoAdminRequired
190
	 * @NoCSRFRequired
191
	 */
192
	public function importFromFile(string $filePath) : JSONResponse {
193
		try {
194
			$userFolder = $this->rootFolder->getUserFolder($this->userId);
195
			$result = $this->playlistFileService->importRadioStationsFromFile($this->userId, $userFolder, $filePath);
196
			$result['stations'] = \array_map(fn($s) => $s->toApi(), $result['stations']);
197
			return new JSONResponse($result);
198
		} catch (\OCP\Files\NotFoundException $ex) {
199
			return new ErrorResponse(Http::STATUS_NOT_FOUND, 'playlist file not found');
200
		} catch (\UnexpectedValueException $ex) {
201
			return new ErrorResponse(Http::STATUS_UNSUPPORTED_MEDIA_TYPE, $ex->getMessage());
202
		}
203
	}
204
205
	/**
206
	 * reset all the radio stations of the user
207
	 *
208
	 * @NoAdminRequired
209
	 * @NoCSRFRequired
210
	 */
211
	public function resetAll() : JSONResponse {
212
		$this->businessLayer->deleteAll($this->userId);
213
		return new JSONResponse(['success' => true]);
214
	}
215
216
	/**
217
	* get metadata for a channel
218
	*
219
	* @NoAdminRequired
220
	* @NoCSRFRequired
221
	*/
222
	public function getChannelInfo(int $id, ?string $type=null) : JSONResponse {
223
		try {
224
			$station = $this->businessLayer->find($id, $this->userId);
225
			$streamUrl = $station->getStreamUrl();
226
227
			switch ($type) {
228
				case 'icy':
229
					$metadata = $this->service->readIcyMetadata($streamUrl, 3, 5);
230
					break;
231
				case 'shoutcast-v1':
232
					$metadata = $this->service->readShoutcastV1Metadata($streamUrl);
233
					break;
234
				case 'shoutcast-v2':
235
					$metadata = $this->service->readShoutcastV2Metadata($streamUrl);
236
					break;
237
				case 'icecast':
238
					$metadata = $this->service->readIcecastMetadata($streamUrl);
239
					break;
240
				default:
241
					$metadata = $this->service->readIcyMetadata($streamUrl, 3, 5)
242
							?? $this->service->readShoutcastV2Metadata($streamUrl)
243
							?? $this->service->readIcecastMetadata($streamUrl)
244
							?? $this->service->readShoutcastV1Metadata($streamUrl);
245
					break;
246
			}
247
248
			return new JSONResponse($metadata);
249
		} catch (BusinessLayerException $ex) {
250
			return new ErrorResponse(Http::STATUS_NOT_FOUND, $ex->getMessage());
251
		}
252
	}
253
254
	/**
255
	 * get stream URL for a radio station
256
	 *
257
	 * @NoAdminRequired
258
	 * @NoCSRFRequired
259
	 */
260
	public function stationStreamUrl(int $id) : JSONResponse {
261
		try {
262
			$station = $this->businessLayer->find($id, $this->userId);
263
			return $this->doResolveStreamUrl($station->getStreamUrl());
264
		} catch (BusinessLayerException $ex) {
265
			return new ErrorResponse(Http::STATUS_NOT_FOUND, $ex->getMessage());
266
		}
267
	}
268
269
	/**
270
	 * get audio stream for a radio station
271
	 *
272
	 * @NoAdminRequired
273
	 * @NoCSRFRequired
274
	 */
275
	public function stationStream(int $id) : Response {
276
		try {
277
			$station = $this->businessLayer->find($id, $this->userId);
278
			$streamUrl = $station->getStreamUrl();
279
			$resolved = $this->service->resolveStreamUrl($streamUrl);
280
			if ($this->streamRelayEnabled()) {
281
				return new RelayStreamResponse($resolved['url']);
282
			} else {
283
				return new RedirectResponse($resolved['url']);
284
			}
285
		} catch (BusinessLayerException $ex) {
286
			return new ErrorResponse(Http::STATUS_NOT_FOUND, $ex->getMessage());
287
		}
288
	}
289
290
	/**
291
	 * get the actual stream URL from the given public URL
292
	 *
293
	 * Available without login since no user data is handled and this may be used on link-shared folder.
294
	 *
295
	 * @PublicPage
296
	 * @NoCSRFRequired
297
	 */
298
	public function resolveStreamUrl(string $url, ?string $token) : JSONResponse {
299
		$url = \rawurldecode($url);
300
301
		if ($token === null) {
302
			return new ErrorResponse(Http::STATUS_UNAUTHORIZED, 'a security token must be passed');
303
		} elseif (!$this->tokenService->urlTokenIsValid($url, \rawurldecode($token))) {
304
			return new ErrorResponse(Http::STATUS_UNAUTHORIZED, 'the security token is invalid');
305
		} else {
306
			return $this->doResolveStreamUrl($url);
307
		}
308
	}
309
310
	private function doResolveStreamUrl(string $url) : JSONResponse {
311
		$resolved = $this->service->resolveStreamUrl($url);
312
		$relayEnabled = $this->streamRelayEnabled();
313
		if ($resolved['url'] === null) {
314
			return new ErrorResponse(Http::STATUS_NOT_FOUND, 'failed to read from the stream URL');
315
		} else {
316
			$token = $this->tokenService->tokenForUrl($resolved['url']);
317
			if ($resolved['hls']) {
318
				$resolved['url'] = $this->urlGenerator->linkToRoute('music.radioApi.hlsManifest',
319
					['url' => \rawurlencode($resolved['url']), 'token' => \rawurlencode($token)]);
320
			} elseif ($relayEnabled) {
321
				$resolved['url'] = $this->urlGenerator->linkToRoute('music.radioApi.streamFromUrl',
322
					['url' => \rawurlencode($resolved['url']), 'token' => \rawurlencode($token)]);
323
			}
324
		}
325
		return new JSONResponse($resolved);
326
	}
327
328
	/**
329
	 * create a relayed stream for the given URL if relaying enabled; otherwise just redirect to the URL
330
	 * 
331
	 * @PublicPage
332
	 * @NoCSRFRequired
333
	 */
334
	public function streamFromUrl(string $url, ?string $token) : Response {
335
		$url = \rawurldecode($url);
336
337
		if ($token === null) {
338
			return new ErrorResponse(Http::STATUS_UNAUTHORIZED, 'a security token must be passed');
339
		} elseif (!$this->tokenService->urlTokenIsValid($url, \rawurldecode($token))) {
340
			return new ErrorResponse(Http::STATUS_UNAUTHORIZED, 'the security token is invalid');
341
		} elseif ($this->streamRelayEnabled()) {
342
			return new RelayStreamResponse($url);
343
		} else {
344
			return new RedirectResponse($url);
345
		}
346
	}
347
348
	/**
349
	 * get manifest of a HLS stream
350
	 *
351
	 * This fetches the manifest file from the given URL and returns a modified version of it.
352
	 * The front-end can't easily stream directly from the original source because of the Content-Security-Policy.
353
	 *
354
	 * @PublicPage
355
	 * @NoCSRFRequired
356
	 */
357
	public function hlsManifest(string $url, ?string $token) : Response {
358
		$url = \rawurldecode($url);
359
360
		if (!$this->hlsEnabled()) {
361
			return new ErrorResponse(Http::STATUS_FORBIDDEN, 'the cloud admin has disabled HLS streaming');
362
		} elseif ($token === null) {
363
			return new ErrorResponse(Http::STATUS_UNAUTHORIZED, 'a security token must be passed');
364
		} elseif (!$this->tokenService->urlTokenIsValid($url, \rawurldecode($token))) {
365
			return new ErrorResponse(Http::STATUS_UNAUTHORIZED, 'the security token is invalid');
366
		} else {
367
			list('content' => $content, 'status_code' => $status, 'content_type' => $contentType)
368
				= $this->service->getHlsManifest($url);
369
370
			return new FileResponse([
371
				'content' => $content,
372
				'mimetype' => $contentType
373
			], $status);
374
		}
375
	}
376
377
	/**
378
	 * get one segment of a HLS stream
379
	 *
380
	 * The segment is fetched from the given URL and relayed as such to the client.
381
	 *
382
	 * @PublicPage
383
	 * @NoCSRFRequired
384
	 */
385
	public function hlsSegment(string $url, ?string $token) : Response {
386
		$url = \rawurldecode($url);
387
388
		if (!$this->hlsEnabled()) {
389
			return new ErrorResponse(Http::STATUS_FORBIDDEN, 'the cloud admin has disabled HLS streaming');
390
		} elseif ($token === null) {
391
			return new ErrorResponse(Http::STATUS_UNAUTHORIZED, 'a security token must be passed');
392
		} elseif (!$this->tokenService->urlTokenIsValid($url, \rawurldecode($token))) {
393
			return new ErrorResponse(Http::STATUS_UNAUTHORIZED, 'the security token is invalid');
394
		} else {
395
			list('content' => $content, 'status_code' => $status, 'content_type' => $contentType)
396
				= HttpUtil::loadFromUrl($url);
397
398
			return new FileResponse([
399
				'content' => $content,
400
				'mimetype' => $contentType ?? 'application/octet-stream'
401
			], $status);
402
		}
403
	}
404
405
	private function hlsEnabled() : bool {
406
		$enabled = (bool)$this->config->getSystemValue('music.enable_radio_hls', true);
407
		if ($this->userId === '') {
408
			$enabled = (bool)$this->config->getSystemValue('music.enable_radio_hls_on_share', $enabled);
409
		}
410
		return $enabled;
411
	}
412
413
	private function streamRelayEnabled() : bool {
414
		$enabled = (bool)$this->config->getSystemValue('music.relay_radio_stream', true);
415
		if ($this->userId === '') {
416
			$enabled = (bool)$this->config->getSystemValue('music.relay_radio_stream_on_share', $enabled);
417
		}
418
		return $enabled;
419
	}
420
}
421