RadioApiController::get()   A
last analyzed

Complexity

Conditions 2
Paths 3

Size

Total Lines 6
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

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