RadioApiController   C
last analyzed

Complexity

Total Complexity 57

Size/Duplication

Total Lines 371
Duplicated Lines 0 %

Importance

Changes 8
Bugs 0 Features 0
Metric Value
eloc 208
c 8
b 0
f 0
dl 0
loc 371
rs 5.04
wmc 57

19 Methods

Rating   Name   Duplication   Size   Complexity  
A streamFromUrl() 0 13 4
A hlsManifest() 0 19 4
A stationStreamUrl() 0 8 2
A resolveStreamUrl() 0 11 3
A hlsSegment() 0 19 4
A create() 0 12 3
A streamRelayEnabled() 0 6 2
A exportAllToFile() 0 14 4
A importFromFile() 0 12 3
A resetAll() 0 5 1
A doResolveStreamUrl() 0 20 4
A getAll() 0 6 1
A delete() 0 8 2
A get() 0 8 2
A stationStream() 0 14 3
A update() 0 14 6
A __construct() 0 23 1
B getChannelInfo() 0 31 6
A hlsEnabled() 0 6 2

How to fix   Complexity   

Complex Class

Complex classes like RadioApiController often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use RadioApiController, and based on these observations, apply Extract Interface, too.

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