RadioService::readIcecastMetadata()   A
last analyzed

Complexity

Conditions 5
Paths 1

Size

Total Lines 34
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Importance

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