* @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::` 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::` 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); } }