RadioApiController::__construct()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 23
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

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

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