<commit_before><?php declare(strict_types=1);

/**
 * ownCloud - Music app
 *
 * This file is licensed under the Affero General Public License version 3 or
 * later. See the COPYING file.
 *
 * @author <NAME> <<EMAIL>>
 * @author <NAME> <<EMAIL>>
 * @copyright Moahmed-Ismail MEJRI 2022
 * @copyright <NAME> 2022
 */

namespace OCA\Music\Utility;

use OCA\Music\AppFramework\Core\Logger;

/**
 * MetaData radio utility functions
 */
class RadioMetadata {

	private $logger;

	public function __construct(Logger $logger) {
		$this->logger = $logger;
	}

	/**
	 * Loop through the array and try to find the given key. On match,
	 * return the text in the array cell following the key.
	 */
	private static function findStrFollowing(array $data, string $key) : ?string {
		foreach ($data as $value) {
			$find = \strstr($value, $key);
			if ($find !== false) {
				return \substr($find, \strlen($key));
			}
		}
		return null;
	}

	private static function parseStreamUrl(string $url) : array {
		$ret = [];
		$parse_url = \parse_url($url);

		$ret['port'] = 80;
		if (isset($parse_url['port'])) {
			$ret['port'] = $parse_url['port'];
		} else if ($parse_url['scheme'] == "https") {
			$ret['port'] = 443;
		}

		$ret['scheme'] = $parse_url['scheme'];
		$ret['hostname'] = $parse_url['host'];
		$ret['pathname'] = $parse_url['path'];

		if (isset($parse_url['query'])) {
			$ret['pathname'] .= "?" . $parse_url['query'];
		}

		if ($parse_url['scheme'] == "https") {
			$ret['sockAddress'] = "ssl://" . $ret['hostname'];
		} else {
			$ret['sockAddress'] = $ret['hostname'];
		}

		return $ret;
	}

	private static function parseTitleFromStreamMetadata($fp) : ?string {
		$meta_length = \ord(\fread($fp, 1)) * 16;
		if ($meta_length) {
			$metadatas = \explode(';', \fread($fp, $meta_length));
			$title = self::findStrFollowing($metadatas, "StreamTitle=");
			if ($title) {
				return Util::truncate(\trim($title, "'"), 256);
			}
		}
		return null;
	}

	private function readMetadata(string $metaUrl, callable $parseResult) : ?array {
		$maxLength = 32 * 1024;
		list('content' => $content, 'status_code' => $status_code, 'message' => $message) = HttpUtil::loadFromUrl($metaUrl, $maxLength);

		if ($status_code == 200) {
			return $parseResult($content);
		} else {
			$this->logger->log("Failed to read $metaUrl: $status_code $message", 'debug');
			return null;
		}
	}

	public function readShoutcastV1Metadata(string $streamUrl) : ?array {
		// cut the URL from the last '/' and append 7.html
		$lastSlash = \strrpos($streamUrl, '/');
		$metaUrl = \substr($streamUrl, 0, $lastSlash) . '/7.html';

		return $this->readMetadata($metaUrl, function ($content) {
			$content = \strip_tags($content); // get rid of the <html><body>...</html></body> decorations
			$data = \explode(',', $content);
			return [
				'type' => 'shoutcast-v1',
				'title' => \count($data) > 6 ? \trim($data[6]) : null // the title field is optional
			];
		});
	}

	public function readShoutcastV2Metadata(string $streamUrl) : ?array {
		// cut the URL from the last '/' and append 'stats'
		$lastSlash = \strrpos($streamUrl, '/');
		$metaUrl = \substr($streamUrl, 0, $lastSlash) . '/stats';

		return $this->readMetadata($metaUrl, function ($content) {
			$rootNode = \simplexml_load_string($content, \SimpleXMLElement::class, LIBXML_NOCDATA);
			return [
				'type' => 'shoutcast-v2',
				'title' => (string)$rootNode->SONGTITLE
			];
		});
	}

	public function readIcecastMetadata(string $streamUrl) : ?array {
		// cut the URL from the last '/' and append 'status-json.xsl'
		$lastSlash = \strrpos($streamUrl, '/');
		$metaUrl = \substr($streamUrl, 0, $lastSlash) . '/status-json.xsl';

		return $this->readMetadata($metaUrl, function ($content) {
			$parsed = \json_decode($content, true);
			return [
				'type' => 'icecast',
				'title' => $parsed['icestats']['source']['title']
						?? $parsed['icestats']['source']['yp_currently_playing']
						?? null
			];
		});
	}

	public function readIcyMetadata(string $streamUrl, int $maxattempts, int $maxredirect) : ?array {
		$timeout = 10;
		$streamTitle = null;
		$pUrl = self::parseStreamUrl($streamUrl);
		if ($pUrl['sockAddress'] && $pUrl['port']) {
			$fp = \fsockopen($pUrl['sockAddress'], $pUrl['port'], $errno, $errstr, $timeout);
			if ($fp !== false) {
				$out = "GET " . $pUrl['pathname'] . " HTTP/1.1\r\n";
				$out .= "Host: ". $pUrl['hostname'] . "\r\n";
				$out .= "Accept: */*\r\n";
				$out .= HttpUtil::userAgentHeader() . "\r\n";
				$out .= "Icy-MetaData: 1\r\n";
				$out .= "Connection: Close\r\n\r\n";
				\fwrite($fp, $out);
				\stream_set_timeout($fp, $timeout);

				$header = \fread($fp, 1024);
				$headers = \explode("\n", $header);

				if (\strpos($headers[0], "200 OK") !== false) {
					$interval = self::findStrFollowing($headers, "icy-metaint:") ?? '0';
					$interval = (int)\trim($interval);

					if ($interval > 0 && $interval <= 64*1024) {
						$attempts = 0;
						while ($attempts < $maxattempts && empty($streamTitle)) {
							$bytesToSkip = $interval;
							if ($attempts === 0) {
								// The first chunk containing the header may also already contain the beginning of the body,
								// but this depends on the case. Subtract the body bytes which we already got.
								$headerEndPos = \strpos($header, "\r\n\r\n") + 4;
								$bytesToSkip -= \strlen($header) - $headerEndPos;
							}

							\fseek($fp, $bytesToSkip, SEEK_CUR);

							$streamTitle = self::parseTitleFromStreamMetadata($fp);

							$attempts++;
						}
					}
					\fclose($fp);
				} else {
					\fclose($fp);
					if ($maxredirect > 0 && \strpos($headers[0], "302 Found") !== false) {
						$location = self::findStrFollowing($headers, "Location: ");
						if ($location) {
							$location = \trim($location, "\r");
							return $this->readIcyMetadata($location, $maxattempts, $maxredirect-1);
						}
					}
				}
			}
		}

		return empty($streamTitle) ? null : ['type' => 'icy', 'title' => $streamTitle];
	}

}
<commit_msg>Make the Icecast metadata parsing function in more cases<commit_after><?php declare(strict_types=1);

/**
 * ownCloud - Music app
 *
 * This file is licensed under the Affero General Public License version 3 or
 * later. See the COPYING file.
 *
 * @author <NAME> <<EMAIL>>
 * @author <NAME> <<EMAIL>>
 * @copyright Moahmed-Ismail MEJRI 2022
 * @copyright <NAME> 2022
 */

namespace OCA\Music\Utility;

use OCA\Music\AppFramework\Core\Logger;

/**
 * MetaData radio utility functions
 */
class RadioMetadata {

	private $logger;

	public function __construct(Logger $logger) {
		$this->logger = $logger;
	}

	/**
	 * Loop through the array and try to find the given key. On match,
	 * return the text in the array cell following the key.
	 */
	private static function findStrFollowing(array $data, string $key) : ?string {
		foreach ($data as $value) {
			$find = \strstr($value, $key);
			if ($find !== false) {
				return \substr($find, \strlen($key));
			}
		}
		return null;
	}

	private static function parseStreamUrl(string $url) : array {
		$ret = [];
		$parse_url = \parse_url($url);

		$ret['port'] = 80;
		if (isset($parse_url['port'])) {
			$ret['port'] = $parse_url['port'];
		} else if ($parse_url['scheme'] == "https") {
			$ret['port'] = 443;
		}

		$ret['scheme'] = $parse_url['scheme'];
		$ret['hostname'] = $parse_url['host'];
		$ret['pathname'] = $parse_url['path'];

		if (isset($parse_url['query'])) {
			$ret['pathname'] .= "?" . $parse_url['query'];
		}

		if ($parse_url['scheme'] == "https") {
			$ret['sockAddress'] = "ssl://" . $ret['hostname'];
		} else {
			$ret['sockAddress'] = $ret['hostname'];
		}

		return $ret;
	}

	private static function parseTitleFromStreamMetadata($fp) : ?string {
		$meta_length = \ord(\fread($fp, 1)) * 16;
		if ($meta_length) {
			$metadatas = \explode(';', \fread($fp, $meta_length));
			$title = self::findStrFollowing($metadatas, "StreamTitle=");
			if ($title) {
				return Util::truncate(\trim($title, "'"), 256);
			}
		}
		return null;
	}

	private function readMetadata(string $metaUrl, callable $parseResult) : ?array {
		$maxLength = 32 * 1024;
		list('content' => $content, 'status_code' => $status_code, 'message' => $message) = HttpUtil::loadFromUrl($metaUrl, $maxLength);

		if ($status_code == 200) {
			return $parseResult($content);
		} else {
			$this->logger->log("Failed to read $metaUrl: $status_code $message", 'debug');
			return null;
		}
	}

	public function readShoutcastV1Metadata(string $streamUrl) : ?array {
		// cut the URL from the last '/' and append 7.html
		$lastSlash = \strrpos($streamUrl, '/');
		$metaUrl = \substr($streamUrl, 0, $lastSlash) . '/7.html';

		return $this->readMetadata($metaUrl, function ($content) {
			$content = \strip_tags($content); // get rid of the <html><body>...</html></body> decorations
			$data = \explode(',', $content);
			return [
				'type' => 'shoutcast-v1',
				'title' => \count($data) > 6 ? \trim($data[6]) : null // the title field is optional
			];
		});
	}

	public function readShoutcastV2Metadata(string $streamUrl) : ?array {
		// cut the URL from the last '/' and append 'stats'
		$lastSlash = \strrpos($streamUrl, '/');
		$metaUrl = \substr($streamUrl, 0, $lastSlash) . '/stats';

		return $this->readMetadata($metaUrl, function ($content) {
			$rootNode = \simplexml_load_string($content, \SimpleXMLElement::class, LIBXML_NOCDATA);
			return [
				'type' => 'shoutcast-v2',
				'title' => (string)$rootNode->SONGTITLE
			];
		});
	}

	public function readIcecastMetadata(string $streamUrl) : ?array {
		// cut the URL from the last '/' and append 'status-json.xsl'
		$lastSlash = \strrpos($streamUrl, '/');
		$metaUrl = \substr($streamUrl, 0, $lastSlash) . '/status-json.xsl';

		return $this->readMetadata($metaUrl, function ($content) use ($streamUrl) {
			\mb_substitute_character(0xFFFD); // Use the Unicode REPLACEMENT CHARACTER (U+FFFD)
			$content = \mb_convert_encoding($content, 'UTF-8', 'UTF-8');
			$parsed = \json_decode($content, true);
			$source = $parsed['icestats']['source'] ?? null;

			if (!\is_array($source)) {
				return null;
			} else {
				// There may be one or multiple sources and the structure is slightly different in these two cases.
				// In case there are multiple, try to found the source with a matching stream URL.
				if (\is_int(\key($source))) {
					// multiple sources
					foreach ($source as $sourceItem) {
						if ($sourceItem['listenurl'] == $streamUrl) {
							$source = $sourceItem;
							break;
						}
					}
				}

				return [
					'type' => 'icecast',
					'title' => $source['title'] ?? $source['yp_currently_playing'] ?? null
				];
			}
		});
	}

	public function readIcyMetadata(string $streamUrl, int $maxattempts, int $maxredirect) : ?array {
		$timeout = 10;
		$streamTitle = null;
		$pUrl = self::parseStreamUrl($streamUrl);
		if ($pUrl['sockAddress'] && $pUrl['port']) {
			$fp = \fsockopen($pUrl['sockAddress'], $pUrl['port'], $errno, $errstr, $timeout);
			if ($fp !== false) {
				$out = "GET " . $pUrl['pathname'] . " HTTP/1.1\r\n";
				$out .= "Host: ". $pUrl['hostname'] . "\r\n";
				$out .= "Accept: */*\r\n";
				$out .= HttpUtil::userAgentHeader() . "\r\n";
				$out .= "Icy-MetaData: 1\r\n";
				$out .= "Connection: Close\r\n\r\n";
				\fwrite($fp, $out);
				\stream_set_timeout($fp, $timeout);

				$header = \fread($fp, 1024);
				$headers = \explode("\n", $header);

				if (\strpos($headers[0], "200 OK") !== false) {
					$interval = self::findStrFollowing($headers, "icy-metaint:") ?? '0';
					$interval = (int)\trim($interval);

					if ($interval > 0 && $interval <= 64*1024) {
						$attempts = 0;
						while ($attempts < $maxattempts && empty($streamTitle)) {
							$bytesToSkip = $interval;
							if ($attempts === 0) {
								// The first chunk containing the header may also already contain the beginning of the body,
								// but this depends on the case. Subtract the body bytes which we already got.
								$headerEndPos = \strpos($header, "\r\n\r\n") + 4;
								$bytesToSkip -= \strlen($header) - $headerEndPos;
							}

							\fseek($fp, $bytesToSkip, SEEK_CUR);

							$streamTitle = self::parseTitleFromStreamMetadata($fp);

							$attempts++;
						}
					}
					\fclose($fp);
				} else {
					\fclose($fp);
					if ($maxredirect > 0 && \strpos($headers[0], "302 Found") !== false) {
						$location = self::findStrFollowing($headers, "Location: ");
						if ($location) {
							$location = \trim($location, "\r");
							return $this->readIcyMetadata($location, $maxattempts, $maxredirect-1);
						}
					}
				}
			}
		}

		return empty($streamTitle) ? null : ['type' => 'icy', 'title' => $streamTitle];
	}

}

{'fraction_non_alphanumeric': 0.19112304533249092, 'fraction_numerical': 0.01145158742694677, 'mean_word_length': 3.4014598540145986, 'pattern_counts': {'":': 0, '<': 31, '<?xml version=': 0, '>': 69, 'https://': 0, 'lorem ipsum': 0, 'www.': 0, 'xml': 2}, 'pii_count': 0, 'substrings_counts': 0, 'word_list_counts': {'cursed_substrings.json': 0, 'profanity_word_list.json': 0, 'sexual_word_list.json': 0, 'zh_pornsignals.json': 0}}
{'dir': 'git-commits-cleaned', 'id': '6513527', 'n_tokens_mistral': 5175, 'n_tokens_neox': 4298, 'n_words': 1021}