RadioService::readIcyMetadata()   C
last analyzed

Complexity

Conditions 13
Paths 7

Size

Total Lines 65
Code Lines 45

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 13
eloc 45
c 1
b 0
f 0
nc 7
nop 3
dl 0
loc 65
rs 6.6166

How to fix   Long Method    Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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 Moahmed-Ismail MEJRI <[email protected]>
10
 * @author Pauli Järvinen <[email protected]>
11
 * @copyright Moahmed-Ismail MEJRI 2022
12
 * @copyright Pauli Järvinen 2022 - 2025
13
 */
14
15
namespace OCA\Music\Service;
16
17
use OCA\Music\AppFramework\Core\Logger;
18
use OCA\Music\Utility\HttpUtil;
19
use OCA\Music\Utility\StringUtil;
20
use OCA\Music\Utility\Util;
21
use OCP\IURLGenerator;
22
23
/**
24
 * MetaData radio utility functions
25
 */
26
class RadioService {
27
28
	private IURLGenerator $urlGenerator;
29
	private StreamTokenService $tokenService;
30
	private Logger $logger;
31
32
	public function __construct(IURLGenerator $urlGenerator, StreamTokenService $tokenService, Logger $logger) {
33
		$this->urlGenerator = $urlGenerator;
34
		$this->tokenService = $tokenService;
35
		$this->logger = $logger;
36
	}
37
38
	/**
39
	 * Loop through the array and try to find the given key. On match, return the
40
	 * text in the array cell following the key. Whitespace is trimmed from the result.
41
	 */
42
	private static function findStrFollowing(array $data, string $key) : ?string {
43
		foreach ($data as $value) {
44
			$find = \strstr($value, $key);
45
			if ($find !== false) {
46
				return \trim(\substr($find, \strlen($key)));
47
			}
48
		}
49
		return null;
50
	}
51
52
	private static function parseStreamUrl(string $url) : array {
53
		$ret = [];
54
		$parse_url = \parse_url($url);
55
56
		$ret['port'] = 80;
57
		if (isset($parse_url['port'])) {
58
			$ret['port'] = $parse_url['port'];
59
		} else if ($parse_url['scheme'] == "https") {
60
			$ret['port'] = 443;
61
		}
62
63
		$ret['scheme'] = $parse_url['scheme'];
64
		$ret['hostname'] = $parse_url['host'];
65
		$ret['pathname'] = $parse_url['path'] ?? '/';
66
67
		if (isset($parse_url['query'])) {
68
			$ret['pathname'] .= "?" . $parse_url['query'];
69
		}
70
71
		if ($parse_url['scheme'] == "https") {
72
			$ret['sockAddress'] = "ssl://" . $ret['hostname'];
73
		} else {
74
			$ret['sockAddress'] = $ret['hostname'];
75
		}
76
77
		return $ret;
78
	}
79
80
	/**
81
	 * @param resource $fp File handle
82
	 */
83
	private static function parseTitleFromStreamMetadata($fp) : ?string {
84
		$meta_length = \ord(\fread($fp, 1)) * 16;
85
		if ($meta_length) {
86
			$metadatas = \explode(';', \fread($fp, $meta_length));
87
			$title = self::findStrFollowing($metadatas, "StreamTitle=");
88
			if ($title) {
89
				return StringUtil::truncate(\trim($title, "'"), 256);
90
			}
91
		}
92
		return null;
93
	}
94
95
	private function readMetadata(string $metaUrl, callable $parseResult) : ?array {
96
		$maxLength = 32 * 1024;
97
		$timeout_s = 8;
98
		list('content' => $content, 'status_code' => $status_code, 'message' => $message)
99
			= HttpUtil::loadFromUrl($metaUrl, $maxLength, $timeout_s);
100
101
		if ($status_code == 200) {
102
			return $parseResult($content);
103
		} else {
104
			$this->logger->debug("Failed to read $metaUrl: $status_code $message");
105
			return null;
106
		}
107
	}
108
109
	public function readShoutcastV1Metadata(string $streamUrl) : ?array {
110
		// cut the URL from the last '/' and append 7.html
111
		$lastSlash = \strrpos($streamUrl, '/');
112
		$metaUrl = \substr($streamUrl, 0, $lastSlash) . '/7.html';
113
114
		return $this->readMetadata($metaUrl, function ($content) {
115
			// parsing logic borrowed from https://github.com/IntellexApps/shoutcast/blob/master/src/Info.php
116
117
			// get rid of the <html><body>...</html></body> decorations and extra spacing:
118
			$content = \preg_replace("[\n\t]", '', \trim(\strip_tags($content)));
119
120
			// parse fields, allowing only the expected format
121
			$match = [];
122
			if (!\preg_match('~^(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,(.*?)$~', $content, $match)) {
123
				return null;
124
			} else {
125
				return [
126
					'type' => 'shoutcast-v1',
127
					'title' => $match[7],
128
					'bitrate' => $match[6]
129
				];
130
			}
131
		});
132
	}
133
134
	public function readShoutcastV2Metadata(string $streamUrl) : ?array {
135
		// cut the URL from the last '/' and append 'stats'
136
		$lastSlash = \strrpos($streamUrl, '/');
137
		$metaUrl = \substr($streamUrl, 0, $lastSlash) . '/stats';
138
139
		return $this->readMetadata($metaUrl, function ($content) {
140
			$rootNode = \simplexml_load_string($content, \SimpleXMLElement::class, LIBXML_NOCDATA);
141
			if ($rootNode === false || $rootNode->getName() != 'SHOUTCASTSERVER') {
142
				return null;
143
			} else {
144
				return [
145
					'type' => 'shoutcast-v2',
146
					'title' => (string)$rootNode->SONGTITLE,
147
					'station' => (string)$rootNode->SERVERTITLE,
148
					'homepage' => (string)$rootNode->SERVERURL,
149
					'genre' => (string)$rootNode->SERVERGENRE,
150
					'bitrate' => (string)$rootNode->BITRATE
151
				];
152
			}
153
		});
154
	}
155
156
	public function readIcecastMetadata(string $streamUrl) : ?array {
157
		// cut the URL from the last '/' and append 'status-json.xsl'
158
		$lastSlash = \strrpos($streamUrl, '/');
159
		$metaUrl = \substr($streamUrl, 0, $lastSlash) . '/status-json.xsl';
160
161
		return $this->readMetadata($metaUrl, function ($content) use ($streamUrl) {
162
			\mb_substitute_character(0xFFFD); // Use the Unicode REPLACEMENT CHARACTER (U+FFFD)
163
			$content = \mb_convert_encoding($content, 'UTF-8', 'UTF-8');
164
			$parsed = \json_decode(/** @scrutinizer ignore-type */ $content, true);
165
			$source = $parsed['icestats']['source'] ?? null;
166
167
			if (!\is_array($source)) {
168
				return null;
169
			} else {
170
				// There may be one or multiple sources and the structure is slightly different in these two cases.
171
				// In case there are multiple, try to found the source with a matching stream URL.
172
				if (\is_int(\key($source))) {
173
					// multiple sources
174
					foreach ($source as $sourceItem) {
175
						if ($sourceItem['listenurl'] == $streamUrl) {
176
							$source = $sourceItem;
177
							break;
178
						}
179
					}
180
				}
181
182
				return [
183
					'type' => 'icecast',
184
					'title' => $source['title'] ?? $source['yp_currently_playing'] ?? null,
185
					'station' => $source['server_name'] ?? null,
186
					'description' => $source['server_description'] ?? null,
187
					'homepage' => $source['server_url'] ?? null,
188
					'genre' => $source['genre'] ?? null,
189
					'bitrate' => $source['bitrate'] ?? null
190
				];
191
			}
192
		});
193
	}
194
195
	public function readIcyMetadata(string $streamUrl, int $maxattempts, int $maxredirect) : ?array {
196
		$timeout = 10;
197
		$result = null;
198
		$pUrl = self::parseStreamUrl($streamUrl);
199
		if ($pUrl['sockAddress'] && $pUrl['port']) {
200
			$fp = \fsockopen($pUrl['sockAddress'], $pUrl['port'], $errno, $errstr, $timeout);
201
			if ($fp !== false) {
202
				$out = "GET " . $pUrl['pathname'] . " HTTP/1.1\r\n";
203
				$out .= "Host: ". $pUrl['hostname'] . "\r\n";
204
				$out .= "Accept: */*\r\n";
205
				$out .= HttpUtil::userAgentHeader() . "\r\n";
206
				$out .= "Icy-MetaData: 1\r\n";
207
				$out .= "Connection: Close\r\n\r\n";
208
				\fwrite($fp, $out);
209
				\stream_set_timeout($fp, $timeout);
210
211
				$header = \fread($fp, 1024);
212
				$headers = \explode("\n", $header);
213
214
				if (\strpos($headers[0], "200 OK") !== false) {
215
					$interval = self::findStrFollowing($headers, "icy-metaint:") ?? '0';
216
					$interval = (int)$interval;
217
218
					if ($interval > 0 && $interval <= 64*1024) {
219
						$result = [
220
							'type' => 'icy',
221
							'title' => null, // fetched below
222
							'station' => self::findStrFollowing($headers, 'icy-name:'),
223
							'description' => self::findStrFollowing($headers, 'icy-description:'),
224
							'homepage' => self::findStrFollowing($headers, 'icy-url:'),
225
							'genre' => self::findStrFollowing($headers, 'icy-genre:'),
226
							'bitrate' => self::findStrFollowing($headers, 'icy-br:')
227
						];
228
229
						$attempts = 0;
230
						while ($attempts < $maxattempts && empty($result['title'])) {
231
							$bytesToSkip = $interval;
232
							if ($attempts === 0) {
233
								// The first chunk containing the header may also already contain the beginning of the body,
234
								// but this depends on the case. Subtract the body bytes which we already got.
235
								$headerEndPos = \strpos($header, "\r\n\r\n") + 4;
236
								$bytesToSkip -= \strlen($header) - $headerEndPos;
237
							}
238
239
							\fseek($fp, $bytesToSkip, SEEK_CUR);
240
241
							$result['title'] = self::parseTitleFromStreamMetadata($fp);
242
243
							$attempts++;
244
						}
245
					}
246
					\fclose($fp);
247
				} else {
248
					\fclose($fp);
249
					if ($maxredirect > 0 && \strpos($headers[0], "302 Found") !== false) {
250
						$location = self::findStrFollowing($headers, "Location:");
251
						if ($location) {
252
							$result = $this->readIcyMetadata($location, $maxattempts, $maxredirect-1);
253
						}
254
					}
255
				}
256
			}
257
		}
258
259
		return $result;
260
	}
261
262
	private static function convertUrlOnPlaylistToAbsolute(string $containedUrl, array $playlistUrlParts) : string {
263
		if (!StringUtil::startsWith($containedUrl, 'http://', true) && !StringUtil::startsWith($containedUrl, 'https://', true)) {
264
			$urlParts = $playlistUrlParts;
265
			$path = $urlParts['path'];
266
			$lastSlash = \strrpos($path, '/');
267
			$urlParts['path'] = \substr($path, 0, $lastSlash + 1) . $containedUrl;
268
			unset($urlParts['query']);
269
			unset($urlParts['fragment']);
270
			$containedUrl = Util::buildUrl($urlParts);
271
		}
272
		return $containedUrl;
273
	}
274
275
	/**
276
	 * Sometimes the URL given as stream URL points to a playlist which in turn contains the actual
277
	 * URL to be streamed. This function resolves such indirections.
278
	 */
279
	public function resolveStreamUrl(string $url) : array {
280
		// the default output for non-playlist URLs:
281
		$resolvedUrl = $url;
282
		$isHls = false;
283
284
		$urlParts = \parse_url($url);
285
		$lcPath = \mb_strtolower($urlParts['path'] ?? '/');
286
287
		$isPls = StringUtil::endsWith($lcPath, '.pls');
288
		$isM3u = !$isPls && (StringUtil::endsWith($lcPath, '.m3u') || StringUtil::endsWith($lcPath, '.m3u8'));
289
290
		if ($isPls || $isM3u) {
291
			$maxLength = 8 * 1024;
292
			list('content' => $content, 'status_code' => $status_code, 'message' => $message) = HttpUtil::loadFromUrl($url, $maxLength);
293
294
			if ($status_code != 200) {
295
				$this->logger->debug("Could not read radio playlist from $url: $status_code $message");
296
			} elseif (\strlen($content) >= $maxLength) {
297
				$this->logger->debug("The URL $url seems to be the stream although the extension suggests it's a playlist");
298
			} else if ($isPls) {
299
				$entries = PlaylistFileService::parsePlsContent($content);
300
			} else {
301
				$isHls = (\strpos($content, '#EXT-X-MEDIA-SEQUENCE') !== false);
302
				if ($isHls) {
303
					$token = $this->tokenService->tokenForUrl($url);
304
					$resolvedUrl = $this->urlGenerator->linkToRoute('music.radioApi.hlsManifest',
305
							['url' => \rawurlencode($url), 'token' => \rawurlencode($token)]);
306
				} else {
307
					$entries = PlaylistFileService::parseM3uContent($content);
308
				}
309
			}
310
311
			if (!empty($entries)) {
312
				$resolvedUrl = $entries[0]['path'];
313
				// the path in the playlist may be relative => convert to absolute
314
				$resolvedUrl = self::convertUrlOnPlaylistToAbsolute($resolvedUrl, $urlParts);
315
			}
316
		}
317
318
		// make a recursive call if the URL got changed
319
		if (!$isHls && $url != $resolvedUrl) {
320
			return $this->resolveStreamUrl($resolvedUrl);
321
		} else {
322
			return [
323
				'url' => $resolvedUrl,
324
				'hls' => $isHls
325
			];
326
		}
327
	}
328
329
	public function getHlsManifest(string $url) : array {
330
		$maxLength = 8 * 1024;
331
		$result = HttpUtil::loadFromUrl($url, $maxLength);
332
333
		if ($result['status_code'] == 200) {
334
			$manifestUrlParts = \parse_url($url);
335
336
			// read the manifest line-by-line, and create a modified copy where each fragment URL is relayed through this server
337
			$fp = \fopen("php://temp", 'r+');
338
			\assert($fp !== false, 'Unexpected error: opening temporary stream failed');
339
340
			\fputs($fp, /** @scrutinizer ignore-type */ $result['content']);
341
			\rewind($fp);
342
343
			$content = '';
344
			while ($line = \fgets($fp)) {
345
				$line = \trim($line);
346
				if (!empty($line) && !StringUtil::startsWith($line, '#')) {
347
					$segUrl = self::convertUrlOnPlaylistToAbsolute($line, $manifestUrlParts);
348
					$segToken = $this->tokenService->tokenForUrl($segUrl);
349
					$line = $this->urlGenerator->linkToRoute('music.radioApi.hlsSegment',
350
							['url' => \rawurlencode($segUrl), 'token' => \rawurlencode($segToken)]);
351
				}
352
				$content .= $line . "\n";
353
			}
354
			$result['content'] = $content;
355
356
			\fclose($fp);
357
		} else {
358
			$this->logger->warning("Failed to read manifest from $url: {$result['status_code']} {$result['message']}");
359
		}
360
361
		return $result;
362
	}
363
364
}
365