Files
XC_VM/src/Core/Util/ImageUtils.php
T
Divarion_D 16399dcc3d refactor(util): delete dead ImageUtils::resize() and generateThumbnail()
Both methods had zero references and were commented out on 2026-09-13
pending confirmation; remove them and their DEAD_CODE.md entries.
2026-09-25 21:33:05 +03:00

144 lines
5.6 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|null $rURL Image URL or internal `s:` reference; null/empty (a stream,
* movie or series without an image — the columns are
* nullable) resolves to ''.
* @param string|null $rForceProtocol Force http/https when resolving server URLs.
* @return string Resolved public URL ('' if there is none or the server cannot be resolved).
*/
public static function validateURL(?string $rURL = null, ?string $rForceProtocol = null): string {
if (empty($rURL)) {
return '';
}
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;
}
/**
* 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|null $rImage Remote image URL; callers pass optional columns
* (an icon, a backdrop), so it may be null.
* @param int|null $rType Optional stream type (unused placeholder).
* @return string|null Internal `s:` reference, or $rImage unchanged.
*/
public static function downloadImage(?string $rImage, ?int $rType = null) {
if ((string) $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 ((string) $rData !== '') {
$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 = $origWidth * $ratio;
$newHeight = $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);
}
}