Passed
Push — master ( f358a5...b5f949 )
by Pauli
03:17
created

RadioService::findStrFollowing()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

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