Passed
Push — master ( 4ba04f...598605 )
by Pauli
03:26 queued 13s
created

RadioService::parseStreamUrl()   A

Complexity

Conditions 5
Paths 12

Size

Total Lines 26
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 5
eloc 17
nc 12
nop 1
dl 0
loc 26
rs 9.3888
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 Moahmed-Ismail MEJRI <[email protected]>
10
 * @author Pauli Järvinen <[email protected]>
11
 * @copyright Moahmed-Ismail MEJRI 2022
12
 * @copyright Pauli Järvinen 2022
13
 */
14
15
namespace OCA\Music\Utility;
16
17
use OCA\Music\AppFramework\Core\Logger;
18
19
/**
20
 * MetaData radio utility functions
21
 */
22
class RadioService {
23
24
	private $logger;
25
26
	public function __construct(Logger $logger) {
27
		$this->logger = $logger;
28
	}
29
30
	/**
31
	 * Loop through the array and try to find the given key. On match, return the
32
	 * text in the array cell following the key. Whitespace is trimmed from the result.
33
	 */
34
	private static function findStrFollowing(array $data, string $key) : ?string {
35
		foreach ($data as $value) {
36
			$find = \strstr($value, $key);
37
			if ($find !== false) {
38
				return \trim(\substr($find, \strlen($key)));
39
			}
40
		}
41
		return null;
42
	}
43
44
	private static function parseStreamUrl(string $url) : array {
45
		$ret = [];
46
		$parse_url = \parse_url($url);
47
48
		$ret['port'] = 80;
49
		if (isset($parse_url['port'])) {
50
			$ret['port'] = $parse_url['port'];
51
		} else if ($parse_url['scheme'] == "https") {
52
			$ret['port'] = 443;
53
		}
54
55
		$ret['scheme'] = $parse_url['scheme'];
56
		$ret['hostname'] = $parse_url['host'];
57
		$ret['pathname'] = $parse_url['path'];
58
59
		if (isset($parse_url['query'])) {
60
			$ret['pathname'] .= "?" . $parse_url['query'];
61
		}
62
63
		if ($parse_url['scheme'] == "https") {
64
			$ret['sockAddress'] = "ssl://" . $ret['hostname'];
65
		} else {
66
			$ret['sockAddress'] = $ret['hostname'];
67
		}
68
69
		return $ret;
70
	}
71
72
	private static function parseTitleFromStreamMetadata($fp) : ?string {
73
		$meta_length = \ord(\fread($fp, 1)) * 16;
74
		if ($meta_length) {
75
			$metadatas = \explode(';', \fread($fp, $meta_length));
76
			$title = self::findStrFollowing($metadatas, "StreamTitle=");
77
			if ($title) {
78
				return Util::truncate(\trim($title, "'"), 256);
79
			}
80
		}
81
		return null;
82
	}
83
84
	private function readMetadata(string $metaUrl, callable $parseResult) : ?array {
85
		$maxLength = 32 * 1024;
86
		list('content' => $content, 'status_code' => $status_code, 'message' => $message) = HttpUtil::loadFromUrl($metaUrl, $maxLength);
87
88
		if ($status_code == 200) {
89
			return $parseResult($content);
90
		} else {
91
			$this->logger->log("Failed to read $metaUrl: $status_code $message", 'debug');
92
			return null;
93
		}
94
	}
95
96
	public function readShoutcastV1Metadata(string $streamUrl) : ?array {
97
		// cut the URL from the last '/' and append 7.html
98
		$lastSlash = \strrpos($streamUrl, '/');
99
		$metaUrl = \substr($streamUrl, 0, $lastSlash) . '/7.html';
100
101
		return $this->readMetadata($metaUrl, function ($content) {
102
			$content = \strip_tags($content); // get rid of the <html><body>...</html></body> decorations
103
			$data = \explode(',', $content);
104
			return [
105
				'type' => 'shoutcast-v1',
106
				'title' => \count($data) > 6 ? \trim($data[6]) : null, // the title field is optional
107
				'bitrate' => $data[5] ?? null
108
			];
109
		});
110
	}
111
112
	public function readShoutcastV2Metadata(string $streamUrl) : ?array {
113
		// cut the URL from the last '/' and append 'stats'
114
		$lastSlash = \strrpos($streamUrl, '/');
115
		$metaUrl = \substr($streamUrl, 0, $lastSlash) . '/stats';
116
117
		return $this->readMetadata($metaUrl, function ($content) {
118
			$rootNode = \simplexml_load_string($content, \SimpleXMLElement::class, LIBXML_NOCDATA);
119
			return [
120
				'type' => 'shoutcast-v2',
121
				'title' => (string)$rootNode->SONGTITLE,
122
				'station' => (string)$rootNode->SERVERTITLE,
123
				'homepage' => (string)$rootNode->SERVERURL,
124
				'genre' => (string)$rootNode->SERVERGENRE,
125
				'bitrate' => (string)$rootNode->BITRATE
126
			];
127
		});
128
	}
129
130
	public function readIcecastMetadata(string $streamUrl) : ?array {
131
		// cut the URL from the last '/' and append 'status-json.xsl'
132
		$lastSlash = \strrpos($streamUrl, '/');
133
		$metaUrl = \substr($streamUrl, 0, $lastSlash) . '/status-json.xsl';
134
135
		return $this->readMetadata($metaUrl, function ($content) use ($streamUrl) {
136
			\mb_substitute_character(0xFFFD); // Use the Unicode REPLACEMENT CHARACTER (U+FFFD)
137
			$content = \mb_convert_encoding($content, 'UTF-8', 'UTF-8');
138
			$parsed = \json_decode(/** @scrutinizer ignore-type */ $content, true);
139
			$source = $parsed['icestats']['source'] ?? null;
140
141
			if (!\is_array($source)) {
142
				return null;
143
			} else {
144
				// There may be one or multiple sources and the structure is slightly different in these two cases.
145
				// In case there are multiple, try to found the source with a matching stream URL.
146
				if (\is_int(\key($source))) {
147
					// multiple sources
148
					foreach ($source as $sourceItem) {
149
						if ($sourceItem['listenurl'] == $streamUrl) {
150
							$source = $sourceItem;
151
							break;
152
						}
153
					}
154
				}
155
156
				return [
157
					'type' => 'icecast',
158
					'title' => $source['title'] ?? $source['yp_currently_playing'] ?? null,
159
					'station' => $source['server_name'] ?? null,
160
					'description' => $source['server_description'] ?? null,
161
					'homepage' => $source['server_url'] ?? null,
162
					'genre' => $source['genre'] ?? null,
163
					'bitrate' => $source['bitrate'] ?? null
164
				];
165
			}
166
		});
167
	}
168
169
	public function readIcyMetadata(string $streamUrl, int $maxattempts, int $maxredirect) : ?array {
170
		$timeout = 10;
171
		$result = null;
172
		$pUrl = self::parseStreamUrl($streamUrl);
173
		if ($pUrl['sockAddress'] && $pUrl['port']) {
174
			$fp = \fsockopen($pUrl['sockAddress'], $pUrl['port'], $errno, $errstr, $timeout);
175
			if ($fp !== false) {
176
				$out = "GET " . $pUrl['pathname'] . " HTTP/1.1\r\n";
177
				$out .= "Host: ". $pUrl['hostname'] . "\r\n";
178
				$out .= "Accept: */*\r\n";
179
				$out .= HttpUtil::userAgentHeader() . "\r\n";
180
				$out .= "Icy-MetaData: 1\r\n";
181
				$out .= "Connection: Close\r\n\r\n";
182
				\fwrite($fp, $out);
183
				\stream_set_timeout($fp, $timeout);
184
185
				$header = \fread($fp, 1024);
186
				$headers = \explode("\n", $header);
187
188
				if (\strpos($headers[0], "200 OK") !== false) {
189
					$interval = self::findStrFollowing($headers, "icy-metaint:") ?? '0';
190
					$interval = (int)$interval;
191
192
					if ($interval > 0 && $interval <= 64*1024) {
193
						$result = [
194
							'type' => 'icy',
195
							'title' => null, // fetched below
196
							'station' => self::findStrFollowing($headers, 'icy-name:'),
197
							'description' => self::findStrFollowing($headers, 'icy-description:'),
198
							'homepage' => self::findStrFollowing($headers, 'icy-url:'),
199
							'genre' => self::findStrFollowing($headers, 'icy-genre:'),
200
							'bitrate' => self::findStrFollowing($headers, 'icy-br:')
201
						];
202
203
						$attempts = 0;
204
						while ($attempts < $maxattempts && empty($result['title'])) {
205
							$bytesToSkip = $interval;
206
							if ($attempts === 0) {
207
								// The first chunk containing the header may also already contain the beginning of the body,
208
								// but this depends on the case. Subtract the body bytes which we already got.
209
								$headerEndPos = \strpos($header, "\r\n\r\n") + 4;
210
								$bytesToSkip -= \strlen($header) - $headerEndPos;
211
							}
212
213
							\fseek($fp, $bytesToSkip, SEEK_CUR);
214
215
							$result['title'] = self::parseTitleFromStreamMetadata($fp);
216
217
							$attempts++;
218
						}
219
					}
220
					\fclose($fp);
221
				} else {
222
					\fclose($fp);
223
					if ($maxredirect > 0 && \strpos($headers[0], "302 Found") !== false) {
224
						$location = self::findStrFollowing($headers, "Location:");
225
						if ($location) {
226
							$result = $this->readIcyMetadata($location, $maxattempts, $maxredirect-1);
227
						}
228
					}
229
				}
230
			}
231
		}
232
233
		return $result;
234
	}
235
236
	/**
237
	 * Sometimes the URL given as stream URL points to a playlist which in turn contains the actual
238
	 * URL to be streamed. This function resolves such indirections.
239
	 */
240
	public function resolveStreamUrl(string $url) : array {
241
		// the default output for non-playlist URLs:
242
		$resolvedUrl = $url;
243
		$isHls = false;
244
245
		$urlParts = \parse_url($url);
246
		$lcPath = \mb_strtolower($urlParts['path']);
247
248
		$isPls = Util::endsWith($lcPath, '.pls');
249
		$isM3u = !$isPls && (Util::endsWith($lcPath, '.m3u') || Util::endsWith($lcPath, '.m3u8'));
250
251
		if ($isPls || $isM3u) {
252
			$maxLength = 8 * 1024;
253
			list('content' => $content, 'status_code' => $status_code, 'message' => $message) = HttpUtil::loadFromUrl($url, $maxLength);
254
255
			if ($status_code != 200) {
256
				$this->logger->log("Could not read radio playlist from $url: $status_code $message", 'debug');
257
			} elseif (\strlen($content) >= $maxLength) {
258
				$this->logger->log("The URL $url seems to be the stream although the extension suggests it's a playlist", 'debug');
259
			} else if ($isPls) {
260
				$entries = PlaylistFileService::parsePlsContent($content);
261
			} else {
262
				$isHls = (\strpos($content, '#EXT-X-MEDIA-SEQUENCE') !== false);
263
				if (!$isHls) {
264
					$entries = PlaylistFileService::parseM3uContent($content, 'UTF-8');
265
				}
266
			}
267
268
			if (!empty($entries)) {
269
				$resolvedUrl = $entries[0]['path'];
270
271
				// the path in the playlist may be relative => convert to absolute
272
				if (!Util::startsWith($resolvedUrl, 'http://', true) && !Util::startsWith($resolvedUrl, 'https://', true)) {
273
					$path = $urlParts['path'];
274
					$lastSlash = \strrpos($path, '/');
275
					$urlParts['path'] = \substr($path, 0, $lastSlash + 1) . $resolvedUrl;
276
					unset($urlParts['query']);
277
					unset($urlParts['fragment']);
278
					$resolvedUrl = Util::buildUrl($urlParts);
279
				}
280
			}
281
		}
282
283
		// make a recursive call if the URL got changed
284
		if ($url != $resolvedUrl) {
285
			return $this->resolveStreamUrl($resolvedUrl);
286
		} else {
287
			return [
288
				'url' => $resolvedUrl,
289
				'hls' => $isHls
290
			];
291
		}
292
	}
293
}
294