Passed
Push — master ( 1723b5...d7de35 )
by Pauli
11:26
created

RadioMetadata::readIcecastMetadata()   A

Complexity

Conditions 5
Paths 1

Size

Total Lines 34
Code Lines 23

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 5
eloc 23
c 1
b 0
f 0
nc 1
nop 1
dl 0
loc 34
rs 9.2408
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 RadioMetadata {
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
					$result = [
190
						'type' => 'icy',
191
						'title' => null, // fetched below
192
						'station' => self::findStrFollowing($headers, 'icy-name:'),
193
						'description' => self::findStrFollowing($headers, 'icy-description:'),
194
						'homepage' => self::findStrFollowing($headers, 'icy-url:'),
195
						'genre' => self::findStrFollowing($headers, 'icy-genre:'),
196
						'bitrate' => self::findStrFollowing($headers, 'icy-br:')
197
					];
198
199
					$interval = self::findStrFollowing($headers, "icy-metaint:") ?? '0';
200
					$interval = (int)$interval;
201
202
					if ($interval > 0 && $interval <= 64*1024) {
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