Files
XC_VM/src/Core/Util/ImageUtils.php
T
Divarion_D e5798ae951 chore(dead-code): comment out unused ImageUtils::resize/generateThumbnail + add index
Both methods have zero call sites anywhere under src/ (grep, excluding vendor).
Comment them out rather than delete (kept as a record), and start a dead-code
index (DEAD_CODE.md) tracking such symbols per file with the verification
command.

Also resolves the last PHPStan level-5 error: generateThumbnail contained an
unreachable 32x64 branch (type 5 is matched earlier); the whole method is dead
regardless, so commenting it out clears the finding. PHPStan is now clean.
2026-09-13 16:43:35 +03:00

219 lines
8.2 KiB
PHP

<?php
namespace XcVm\Core\Util;
use XcVm\Core\Config\SettingsManager;
use XcVm\Domain\Server\ServerRepository;
/**
* ImageUtils — image utils
*
* @package XC_VM_Core_Util
* @author Divarion_D <https://github.com/Divarion-D>
* @copyright 2025-2026 Vateron Media
* @link https://github.com/Vateron-Media/XC_VM
* @license AGPL-3.0 https://www.gnu.org/licenses/agpl-3.0.html
*/
class ImageUtils {
/**
* Resolve an image URL, expanding internal `s:<serverId>:` references.
*
* @param string $rURL Image URL or internal `s:` reference.
* @param string|null $rForceProtocol Force http/https when resolving server URLs.
* @return string Resolved public URL ('' if the server cannot be resolved).
*/
public static function validateURL(string $rURL, ?string $rForceProtocol = null) {
if (substr($rURL, 0, 2) == 's:') {
$rSplit = explode(':', $rURL, 3);
$rServerURL = ServerRepository::getPublicURL(intval($rSplit[1]), $rForceProtocol);
if ($rServerURL) {
return $rServerURL . 'images/' . basename($rURL);
}
return '';
}
return $rURL;
}
// /**
// * Return the URL of a cached resized image, or the validated original.
// *
// * @param string $rURL Source image URL.
// * @param int $rMaxW Max width.
// * @param int $rMaxH Max height.
// * @return string Public URL of the resized image, or the validated source.
// */
// public static function resize(string $rURL, int $rMaxW, int $rMaxH) {
// list($rExtension) = explode('.', strtolower(pathinfo($rURL)['extension']));
// $rImagePath = IMAGES_PATH . 'admin/' . md5($rURL) . '_' . $rMaxW . '_' . $rMaxH . '.' . $rExtension;
// if (file_exists($rImagePath)) {
// $rServerInfo = ServerRepository::getAll()[SERVER_ID];
// $rDomain = (empty($rServerInfo['domain_name']) ? $rServerInfo['server_ip'] : explode(',', $rServerInfo['domain_name'])[0]);
// return $rServerInfo['server_protocol'] . '://' . $rDomain . ':' . $rServerInfo['request_port'] . '/images/admin/' . md5($rURL) . '_' . $rMaxW . '_' . $rMaxH . '.' . $rExtension;
// }
// return self::validateURL($rURL);
// }
// /**
// * Generate and cache a thumbnail sized for the given stream type.
// *
// * @param string $rImage Source image path/URL.
// * @param int $rType Stream type controlling target dimensions.
// * @return bool True if the thumbnail exists/was created, false otherwise.
// */
// public static function generateThumbnail(string $rImage, int $rType) {
// if ($rType == 1 || $rType == 5 || $rType == 4) {
// $rMaxW = 96;
// $rMaxH = 32;
// } else {
// if ($rType == 2) {
// $rMaxW = 58;
// $rMaxH = 32;
// } else {
// if ($rType == 5) {
// $rMaxW = 32;
// $rMaxH = 64;
// } else {
// return false;
// }
// }
// }
// list($rExtension) = explode('.', strtolower(pathinfo($rImage)['extension']));
// if (!in_array($rExtension, ['png', 'jpg', 'jpeg'])) {
// } else {
// $rImagePath = IMAGES_PATH . 'admin/' . md5($rImage) . '_' . $rMaxW . '_' . $rMaxH . '.' . $rExtension;
// if (file_exists($rImagePath)) {
// } else {
// if (self::isAbsoluteUrl($rImage)) {
// $rActURL = $rImage;
// } else {
// $rActURL = IMAGES_PATH . basename($rImage);
// }
// list($rWidth, $rHeight) = getimagesize($rActURL);
// $rImageSize = self::getImageSizeKeepAspectRatio($rWidth, $rHeight, $rMaxW, $rMaxH);
// if (!($rImageSize['width'] && $rImageSize['height'])) {
// } else {
// $rImageP = imagecreatetruecolor($rImageSize['width'], $rImageSize['height']);
// if ($rExtension == 'png') {
// $rImage = imagecreatefrompng($rActURL);
// } else {
// $rImage = imagecreatefromjpeg($rActURL);
// }
// imagealphablending($rImageP, false);
// imagesavealpha($rImageP, true);
// imagecopyresampled($rImageP, $rImage, 0, 0, 0, 0, $rImageSize['width'], $rImageSize['height'], $rWidth, $rHeight);
// imagepng($rImageP, $rImagePath);
// }
// }
// if (!file_exists($rImagePath)) {
// } else {
// return true;
// }
// }
// return false;
// }
/**
* Download a remote image into the local image cache.
*
* Stores jpg/jpeg/png images and returns an internal `s:<serverId>:` reference;
* returns the original URL unchanged when not downloadable.
*
* @param string $rImage Remote image URL.
* @param int|null $rType Optional stream type (unused placeholder).
* @return string Internal `s:` reference, or the original URL.
*/
public static function downloadImage(string $rImage, ?int $rType = null) {
if (0 < strlen($rImage) && substr(strtolower($rImage), 0, 4) == 'http') {
$rPathInfo = pathinfo(parse_url($rImage, PHP_URL_PATH) ?: $rImage);
$rExt = strtolower($rPathInfo['extension'] ?? '');
if (!$rExt) {
$rImageInfo = @getimagesize($rImage);
if (is_array($rImageInfo) && !empty($rImageInfo['mime']) && strpos($rImageInfo['mime'], '/') !== false) {
list(, $rExt) = explode('/', strtolower($rImageInfo['mime']), 2);
}
}
if (in_array(strtolower($rExt), ['jpg', 'jpeg', 'png'])) {
$rFilename = Encryption::encrypt($rImage, SettingsManager::get('live_streaming_pass'), OPENSSL_EXTRA);
// A single filename component is capped at 255 bytes on ext4/most
// filesystems. Long source URLs produce an encrypted name that
// exceeds this, so file_put_contents fails with "File name too
// long" and the whole import (e.g. Plex Sync) stalls. Fall back to
// a deterministic hash for those — the tools-images self-heal can't
// reverse a hash, but the image still downloads and caches. The
// "h_" prefix marks these so the self-heal can skip them.
if (strlen($rFilename . '.' . $rExt) > 250) {
$rFilename = 'h_' . hash('sha256', $rImage);
}
$rPrevPath = IMAGES_PATH . $rFilename . '.' . $rExt;
if (file_exists($rPrevPath)) {
return 's:' . SERVER_ID . ':/images/' . $rFilename . '.' . $rExt;
}
$rCurl = curl_init();
curl_setopt($rCurl, CURLOPT_URL, $rImage);
curl_setopt($rCurl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($rCurl, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($rCurl, CURLOPT_TIMEOUT, 5);
$rData = curl_exec($rCurl);
if (strlen($rData) > 0) {
$rPath = IMAGES_PATH . $rFilename . '.' . $rExt;
// The images cache dir may not exist yet on a given node (e.g. an
// LB running the watch import), so file_put_contents would fail
// with ENOENT. Ensure the directory exists before writing.
if (!is_dir(IMAGES_PATH)) {
@mkdir(IMAGES_PATH, 0775, true);
}
@file_put_contents($rPath, $rData);
if (file_exists($rPath)) {
return 's:' . SERVER_ID . ':/images/' . $rFilename . '.' . $rExt;
}
}
}
}
return $rImage;
}
/**
* Compute dimensions that fit within a bounding box, preserving aspect ratio.
*
* @param int $origWidth Original width.
* @param int $origHeight Original height.
* @param int $maxWidth Max width (0 = no limit).
* @param int $maxHeight Max height (0 = no limit).
* @return array ['width' => int, 'height' => int].
*/
public static function getImageSizeKeepAspectRatio(int $origWidth, int $origHeight, int $maxWidth, int $maxHeight) {
if ($maxWidth == 0) {
$maxWidth = $origWidth;
}
if ($maxHeight == 0) {
$maxHeight = $origHeight;
}
$widthRatio = $maxWidth / (($origWidth ?: 1));
$heightRatio = $maxHeight / (($origHeight ?: 1));
$ratio = min($widthRatio, $heightRatio);
if ($ratio < 1) {
$newWidth = (int) $origWidth * $ratio;
$newHeight = (int) $origHeight * $ratio;
} else {
$newHeight = $origHeight;
$newWidth = $origWidth;
}
return ['height' => round($newHeight, 0), 'width' => round($newWidth, 0)];
}
/**
* Determine whether a string is an absolute URL.
*
* @param string $rURL Candidate URL.
* @return bool True if absolute.
*/
public static function isAbsoluteUrl(string $rURL) {
$rPattern = "/^(?:ftp|https?|feed)?:?\\/\\/(?:(?:(?:[\\w\\.\\-\\+!\$&'\\(\\)*\\+,;=]|%[0-9a-f]{2})+:)*" . "\n" . " (?:[\\w\\.\\-\\+%!\$&'\\(\\)*\\+,;=]|%[0-9a-f]{2})+@)?(?:" . "\n" . ' (?:[a-z0-9\\-\\.]|%[0-9a-f]{2})+|(?:\\[(?:[0-9a-f]{0,4}:)*(?:[0-9a-f]{0,4})\\]))(?::[0-9]+)?(?:[\\/|\\?]' . "\n" . " (?:[\\w#!:\\.\\?\\+\\|=&@\$'~*,;\\/\\(\\)\\[\\]\\-]|%[0-9a-f]{2})*)?\$/xi";
return (bool) preg_match($rPattern, $rURL);
}
}