RadioApiController   C
last analyzed

Complexity

Total Complexity 57

Size/Duplication

Total Lines 375
Duplicated Lines 0 %

Importance

Changes 7
Bugs 0 Features 0
Metric Value
eloc 174
dl 0
loc 375
rs 5.04
c 7
b 0
f 0
wmc 57

18 Methods

Rating   Name   Duplication   Size   Complexity  
A streamFromUrl() 0 11 4
A hlsManifest() 0 17 4
A stationStreamUrl() 0 12 4
A resolveStreamUrl() 0 16 5
A hlsSegment() 0 17 4
A create() 0 10 3
A streamRelayEnabled() 0 6 2
A exportAllToFile() 0 12 4
A importFromFile() 0 10 3
A resetAll() 0 3 1
A getAll() 0 4 1
A delete() 0 6 2
A get() 0 6 2
A stationStream() 0 12 3
A update() 0 12 6
A __construct() 0 21 1
B getChannelInfo() 0 29 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 - 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
			$streamUrl = $station->getStreamUrl();
264
			$resolved = $this->service->resolveStreamUrl($streamUrl);
265
			$relayEnabled = $this->streamRelayEnabled();
266
			if ($relayEnabled && !$resolved['hls']) {
267
				$resolved['url'] = $this->urlGenerator->linkToRoute('music.radioApi.stationStream', ['id' => $id]);
268
			}
269
			return new JSONResponse($resolved);
270
		} catch (BusinessLayerException $ex) {
271
			return new ErrorResponse(Http::STATUS_NOT_FOUND, $ex->getMessage());
272
		}
273
	}
274
275
	/**
276
	 * get audio stream for a radio station
277
	 *
278
	 * @NoAdminRequired
279
	 * @NoCSRFRequired
280
	 */
281
	public function stationStream(int $id) : Response {
282
		try {
283
			$station = $this->businessLayer->find($id, $this->userId);
284
			$streamUrl = $station->getStreamUrl();
285
			$resolved = $this->service->resolveStreamUrl($streamUrl);
286
			if ($this->streamRelayEnabled()) {
287
				return new RelayStreamResponse($resolved['url']);
288
			} else {
289
				return new RedirectResponse($resolved['url']);
290
			}
291
		} catch (BusinessLayerException $ex) {
292
			return new ErrorResponse(Http::STATUS_NOT_FOUND, $ex->getMessage());
293
		}
294
	}
295
296
	/**
297
	 * get the actual stream URL from the given public URL
298
	 *
299
	 * Available without login since no user data is handled and this may be used on link-shared folder.
300
	 *
301
	 * @PublicPage
302
	 * @NoCSRFRequired
303
	 */
304
	public function resolveStreamUrl(string $url, ?string $token) : JSONResponse {
305
		$url = \rawurldecode($url);
306
307
		if ($token === null) {
308
			return new ErrorResponse(Http::STATUS_UNAUTHORIZED, 'a security token must be passed');
309
		} elseif (!$this->tokenService->urlTokenIsValid($url, \rawurldecode($token))) {
310
			return new ErrorResponse(Http::STATUS_UNAUTHORIZED, 'the security token is invalid');
311
		} else {
312
			$resolved = $this->service->resolveStreamUrl($url);
313
			$relayEnabled = $this->streamRelayEnabled();
314
			if ($relayEnabled && !$resolved['hls']) {
315
				$token = $this->tokenService->tokenForUrl($resolved['url']);
316
				$resolved['url'] = $this->urlGenerator->linkToRoute('music.radioApi.streamFromUrl',
317
					['url' => \rawurlencode($resolved['url']), 'token' => \rawurlencode($token)]);
318
			}
319
			return new JSONResponse($resolved);
320
		}
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
	 */
329
	public function streamFromUrl(string $url, ?string $token) : Response {
330
		$url = \rawurldecode($url);
331
332
		if ($token === null) {
333
			return new ErrorResponse(Http::STATUS_UNAUTHORIZED, 'a security token must be passed');
334
		} elseif (!$this->tokenService->urlTokenIsValid($url, \rawurldecode($token))) {
335
			return new ErrorResponse(Http::STATUS_UNAUTHORIZED, 'the security token is invalid');
336
		} elseif ($this->streamRelayEnabled()) {
337
			return new RelayStreamResponse($url);
338
		} else {
339
			return new RedirectResponse($url);
340
		}
341
	}
342
343
	/**
344
	 * get manifest of a HLS stream
345
	 *
346
	 * This fetches the manifest file from the given URL and returns a modified version of it.
347
	 * The front-end can't easily stream directly from the original source because of the Content-Security-Policy.
348
	 *
349
	 * @PublicPage
350
	 * @NoCSRFRequired
351
	 */
352
	public function hlsManifest(string $url, ?string $token) : Response {
353
		$url = \rawurldecode($url);
354
355
		if (!$this->hlsEnabled()) {
356
			return new ErrorResponse(Http::STATUS_FORBIDDEN, 'the cloud admin has disabled HLS streaming');
357
		} elseif ($token === null) {
358
			return new ErrorResponse(Http::STATUS_UNAUTHORIZED, 'a security token must be passed');
359
		} elseif (!$this->tokenService->urlTokenIsValid($url, \rawurldecode($token))) {
360
			return new ErrorResponse(Http::STATUS_UNAUTHORIZED, 'the security token is invalid');
361
		} else {
362
			list('content' => $content, 'status_code' => $status, 'content_type' => $contentType)
363
				= $this->service->getHlsManifest($url);
364
365
			return new FileResponse([
366
				'content' => $content,
367
				'mimetype' => $contentType
368
			], $status);
369
		}
370
	}
371
372
	/**
373
	 * get one segment of a HLS stream
374
	 *
375
	 * The segment is fetched from the given URL and relayed as such to the client.
376
	 *
377
	 * @PublicPage
378
	 * @NoCSRFRequired
379
	 */
380
	public function hlsSegment(string $url, ?string $token) : Response {
381
		$url = \rawurldecode($url);
382
383
		if (!$this->hlsEnabled()) {
384
			return new ErrorResponse(Http::STATUS_FORBIDDEN, 'the cloud admin has disabled HLS streaming');
385
		} elseif ($token === null) {
386
			return new ErrorResponse(Http::STATUS_UNAUTHORIZED, 'a security token must be passed');
387
		} elseif (!$this->tokenService->urlTokenIsValid($url, \rawurldecode($token))) {
388
			return new ErrorResponse(Http::STATUS_UNAUTHORIZED, 'the security token is invalid');
389
		} else {
390
			list('content' => $content, 'status_code' => $status, 'content_type' => $contentType)
391
				= HttpUtil::loadFromUrl($url);
392
393
			return new FileResponse([
394
				'content' => $content,
395
				'mimetype' => $contentType ?? 'application/octet-stream'
396
			], $status);
397
		}
398
	}
399
400
	private function hlsEnabled() : bool {
401
		$enabled = (bool)$this->config->getSystemValue('music.enable_radio_hls', true);
402
		if ($this->userId === '') {
403
			$enabled = (bool)$this->config->getSystemValue('music.enable_radio_hls_on_share', $enabled);
404
		}
405
		return $enabled;
406
	}
407
408
	private function streamRelayEnabled() : bool {
409
		$enabled = (bool)$this->config->getSystemValue('music.relay_radio_stream', true);
410
		if ($this->userId === '') {
411
			$enabled = (bool)$this->config->getSystemValue('music.relay_radio_stream_on_share', $enabled);
412
		}
413
		return $enabled;
414
	}
415
}
416