Passed
Push — master ( f85fb3...4c3b66 )
by Pauli
02:11
created

RadioMetadata::readShoutcastV2Metadata()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 8
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 1
eloc 5
c 2
b 0
f 0
nc 1
nop 1
dl 0
loc 8
rs 10
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,
32
	 * return the text in the array cell following the key.
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 \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) : ?string {
85
		list('content' => $content, 'status_code' => $status_code, 'message' => $message) = HttpUtil::loadFromUrl($metaUrl);
86
87
		if ($status_code == 200) {
88
			return $parseResult($content);
89
		} else {
90
			$this->logger->log("Failed to read $metaUrl: $status_code $message", 'debug');
91
			return null;
92
		}
93
	}
94
95
	public function readShoutcastV1Metadata(string $streamUrl) : ?string {
96
		// cut the URL from the last '/' and append 7.html
97
		$lastSlash = \strrpos($streamUrl, '/');
98
		$metaUrl = \substr($streamUrl, 0, $lastSlash) . '/7.html';
99
100
		return $this->readMetadata($metaUrl, function ($content) {
101
			$content = \strip_tags($content); // get rid of the <html><body>...</html></body> decorations
102
			$data = \explode(',', $content);
103
			return \count($data) > 6 ? \trim($data[6]) : null; // the title field is optional
104
		});
105
	}
106
107
	public function readShoutcastV2Metadata(string $streamUrl) : ?string {
108
		// cut the URL from the last '/' and append 'stats'
109
		$lastSlash = \strrpos($streamUrl, '/');
110
		$metaUrl = \substr($streamUrl, 0, $lastSlash) . '/stats';
111
112
		return $this->readMetadata($metaUrl, function ($content) {
113
			$rootNode = \simplexml_load_string($content, \SimpleXMLElement::class, LIBXML_NOCDATA);
114
			return (string)$rootNode->SONGTITLE;
115
		});
116
	}
117
118
	public function readIcacastMetadata(string $streamUrl) : ?string {
119
		// cut the URL from the last '/' and append 'status-json.xsl'
120
		$lastSlash = \strrpos($streamUrl, '/');
121
		$metaUrl = \substr($streamUrl, 0, $lastSlash) . '/status-json.xsl';
122
123
		return $this->readMetadata($metaUrl, function ($content) {
124
			$parsed = \json_decode($content, true);
125
			return $parsed['icecasts']['source']['title']
126
				?? $parsed['icecasts']['source']['yp_currently_playing']
127
				?? null;
128
		});
129
	}
130
131
	public function readIcyMetadata(string $streamUrl, int $maxattempts, int $maxredirect) : ?string {
132
		$timeout = 10;
133
		$streamTitle = null;
134
		$pUrl = self::parseStreamUrl($streamUrl);
135
		if ($pUrl['sockAddress'] && $pUrl['port']) {
136
			$fp = \fsockopen($pUrl['sockAddress'], $pUrl['port'], $errno, $errstr, $timeout);
137
			if ($fp !== false) {
138
				$out = "GET " . $pUrl['pathname'] . " HTTP/1.1\r\n";
139
				$out .= "Host: ". $pUrl['hostname'] . "\r\n";
140
				$out .= "Accept: */*\r\n";
141
				$out .= HttpUtil::userAgentHeader() . "\r\n";
142
				$out .= "Icy-MetaData: 1\r\n";
143
				$out .= "Connection: Close\r\n\r\n";
144
				\fwrite($fp, $out);
145
				\stream_set_timeout($fp, $timeout);
146
147
				$header = \fread($fp, 1024);
148
				$headers = \explode("\n", $header);
149
150
				if (\strpos($headers[0], "200 OK") !== false) {
151
					$interval = self::findStrFollowing($headers, "icy-metaint:") ?? '0';
152
					$interval = (int)\trim($interval);
153
154
					if ($interval > 0 && $interval <= 64*1024) {
155
						$attempts = 0;
156
						while ($attempts < $maxattempts && $streamTitle === null) {
157
							$bytesToSkip = $interval;
158
							if ($attempts === 0) {
159
								// The first chunk containing the header may also already contain the beginning of the body,
160
								// but this depends on the case. Subtract the body bytes which we already got.
161
								$headerEndPos = \strpos($header, "\r\n\r\n") + 4;
162
								$bytesToSkip -= \strlen($header) - $headerEndPos;
163
							}
164
165
							\fseek($fp, $bytesToSkip, SEEK_CUR);
166
167
							$streamTitle = self::parseTitleFromStreamMetadata($fp);
168
169
							$attempts++;
170
						}
171
					}
172
				} else if ($maxredirect > 0 && \strpos($headers[0], "302 Found") !== false) {
173
					$location = self::findStrFollowing($headers, "Location: ");
174
					if ($location) {
175
						$location = \trim($location, "\r");
176
						$streamTitle = $this->readIcyMetadata($location, $maxattempts, $maxredirect-1);
177
					}
178
				}
179
				\fclose($fp);
180
			}
181
		}
182
183
		return $streamTitle === '' ? null : $streamTitle;
184
	}
185
186
}
187