db = $db; $this->logger = $logger; } /** * WebP quality setting (80 is roughly equivalent to JPEG 90) */ const WEBP_QUALITY = 80; /** * Max image width in pixels */ const MAX_IMAGE_WIDTH = 1600; /** * File size threshold for WebP conversion (500KB) */ const WEBP_SIZE_THRESHOLD = 512000; /** * Convert image to WebP format if needed * * Converts to WebP if: * - Image is PNG, OR * - Image is larger than WEBP_SIZE_THRESHOLD * * Also resizes if width > MAX_IMAGE_WIDTH * * @param string $file_path Absolute path to image file * @param string $extension Original file extension * @return array ['path' => new path, 'extension' => new extension, 'converted' => bool] */ private function maybe_convert_to_webp($file_path, $extension) { $result = array( 'path' => $file_path, 'extension' => $extension, 'converted' => false, ); // Skip if already WebP or GIF (preserve animations) if ($extension === 'webp' || $extension === 'gif') { return $result; } $file_size = filesize($file_path); $is_png = ($extension === 'png'); $is_large = ($file_size > self::WEBP_SIZE_THRESHOLD); // Only convert PNGs or large files if (!$is_png && !$is_large) { return $result; } // Check if we have image editing capability if (!function_exists('wp_get_image_editor')) { return $result; } $editor = wp_get_image_editor($file_path); if (is_wp_error($editor)) { $this->logger->warning('Could not load image for WebP conversion', array( 'path' => $file_path, 'error' => $editor->get_error_message(), )); return $result; } // Get current dimensions $size = $editor->get_size(); $needs_resize = ($size['width'] > self::MAX_IMAGE_WIDTH); // Resize if needed if ($needs_resize) { $editor->resize(self::MAX_IMAGE_WIDTH, null, false); } // Set quality (80 is roughly equivalent to JPEG 90) $editor->set_quality(self::WEBP_QUALITY); // Generate WebP path $webp_path = preg_replace('/\.[^.]+$/', '.webp', $file_path); // Save as WebP $saved = $editor->save($webp_path, 'image/webp'); if (is_wp_error($saved)) { $this->logger->warning('WebP conversion failed', array( 'path' => $file_path, 'error' => $saved->get_error_message(), )); return $result; } // Delete original file @unlink($file_path); $this->logger->debug('Converted image to WebP', array( 'original_path' => $file_path, 'original_size' => $file_size, 'webp_path' => $webp_path, 'webp_size' => filesize($webp_path), 'resized' => $needs_resize, )); return array( 'path' => $webp_path, 'extension' => 'webp', 'converted' => true, ); } /** * Check if WebP version of file exists, return that path if so * * @param string $file_path Original file path * @return string Path to use (WebP if exists, otherwise original) */ private function prefer_webp_path($file_path) { $webp_path = preg_replace('/\.[^.]+$/', '.webp', $file_path); if (file_exists($webp_path)) { return $webp_path; } return $file_path; } /** * Get base upload directory for MLS media (standard cache) * * @return string Absolute path */ public function get_upload_dir() { $upload_dir = wp_upload_dir(); return $upload_dir['basedir'] . '/' . self::UPLOAD_SUBDIR; } /** * Get base upload URL for MLS media (standard cache) * * @return string URL */ public function get_upload_url() { $upload_dir = wp_upload_dir(); return $upload_dir['baseurl'] . '/' . self::UPLOAD_SUBDIR; } /** * Get base upload directory for persistent HomeProz media * * @return string Absolute path */ public function get_persistent_upload_dir() { $upload_dir = wp_upload_dir(); return $upload_dir['basedir'] . '/' . self::PERSISTENT_SUBDIR; } /** * Get base upload URL for persistent HomeProz media * * @return string URL */ public function get_persistent_upload_url() { $upload_dir = wp_upload_dir(); return $upload_dir['baseurl'] . '/' . self::PERSISTENT_SUBDIR; } /** * Check if a listing is a HomeProz listing * * @param string $listing_key Listing key * @return bool True if HomeProz listing */ public function is_homeproz_listing($listing_key) { global $wpdb; $is_homeproz = $wpdb->get_var($wpdb->prepare( "SELECT is_homeproz FROM {$this->db->properties_table()} WHERE listing_key = %s", $listing_key )); return (bool) $is_homeproz; } /** * Get storage directory for a specific listing * * HomeProz listings use the persistent cache directory. * Other listings use the standard cache directory (subject to garbage collection). * * @param string $listing_key Listing key * @param bool|null $is_homeproz Override HomeProz check (null = look up from DB) * @return string Absolute path */ public function get_listing_dir($listing_key, $is_homeproz = null) { $prefix = substr($listing_key, 0, 2); // Determine if HomeProz if not explicitly provided if ($is_homeproz === null) { $is_homeproz = $this->is_homeproz_listing($listing_key); } $base_dir = $is_homeproz ? $this->get_persistent_upload_dir() : $this->get_upload_dir(); return $base_dir . '/' . $prefix . '/' . $listing_key; } /** * Get the base URL for a listing's media * * @param string $listing_key Listing key * @param bool|null $is_homeproz Override HomeProz check (null = look up from DB) * @return string URL */ public function get_listing_url($listing_key, $is_homeproz = null) { $prefix = substr($listing_key, 0, 2); if ($is_homeproz === null) { $is_homeproz = $this->is_homeproz_listing($listing_key); } $base_url = $is_homeproz ? $this->get_persistent_upload_url() : $this->get_upload_url(); return $base_url . '/' . $prefix . '/' . $listing_key; } /** * Find existing cached file for a listing, checking both persistent and standard cache * * @param string $listing_key Listing key * @param string $filename Filename to find * @return array|null ['path' => absolute path, 'url' => url, 'persistent' => bool] or null */ public function find_cached_file($listing_key, $filename) { $prefix = substr($listing_key, 0, 2); // Check persistent cache first (HomeProz listings) $persistent_path = $this->get_persistent_upload_dir() . '/' . $prefix . '/' . $listing_key . '/' . $filename; if (file_exists($persistent_path)) { return array( 'path' => $persistent_path, 'url' => $this->get_persistent_upload_url() . '/' . $prefix . '/' . $listing_key . '/' . $filename, 'persistent' => true, ); } // Check standard cache $standard_path = $this->get_upload_dir() . '/' . $prefix . '/' . $listing_key . '/' . $filename; if (file_exists($standard_path)) { return array( 'path' => $standard_path, 'url' => $this->get_upload_url() . '/' . $prefix . '/' . $listing_key . '/' . $filename, 'persistent' => false, ); } return null; } /** * Store media metadata from API sync (no download) * * @param string $listing_key Listing key * @param array $media_array Media array from API * @param callable|null $progress_callback Callback for progress updates * @return array Stats */ public function sync_property_media($listing_key, $media_array, $force = false, $progress_callback = null) { global $wpdb; if (empty($media_array)) { return array('stored' => 0, 'skipped' => 0); } $received_keys = array(); $stored = 0; $skipped = 0; foreach ($media_array as $media) { $media_key = $media['MediaKey'] ?? null; if (!$media_key) { continue; } $received_keys[] = $media_key; // Check if media record exists $existing = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$this->db->media_table()} WHERE listing_key = %s AND media_key = %s", $listing_key, $media_key )); $data = array( 'listing_key' => $listing_key, 'media_key' => $media_key, 'media_type' => $media['MediaType'] ?? 'Photo', 'media_order' => $media['Order'] ?? 0, 'media_url' => $media['MediaURL'] ?? null, 'image_width' => $media['ImageWidth'] ?? null, 'image_height' => $media['ImageHeight'] ?? null, 'media_modification_timestamp' => isset($media['MediaModificationTimestamp']) ? date('Y-m-d H:i:s', strtotime($media['MediaModificationTimestamp'])) : null, 'updated_at' => current_time('mysql'), ); if ($existing) { // Check if URL changed $url_changed = $existing->media_url !== ($media['MediaURL'] ?? null); if ($url_changed && $existing->local_path) { // Check if this is a HomeProz listing $is_homeproz = $this->is_homeproz_listing($listing_key); if ($is_homeproz) { // HomeProz: Keep existing cached file, don't reset local_path // The existing image continues to work even if MLS URL expires // Only replace if a new download succeeds later } else { // Non-HomeProz: Delete old cached files to save space $filename = basename($existing->local_path); $prefix = substr($listing_key, 0, 2); // Delete from standard cache $standard_path = $this->get_upload_dir() . '/' . $prefix . '/' . $listing_key . '/' . $filename; if (file_exists($standard_path)) { unlink($standard_path); } // Also delete WebP versions $webp_filename = preg_replace('/\.[^.]+$/', '.webp', $filename); $webp_standard = $this->get_upload_dir() . '/' . $prefix . '/' . $listing_key . '/' . $webp_filename; if (file_exists($webp_standard)) { unlink($webp_standard); } $data['local_path'] = null; $data['local_url'] = null; $data['downloaded_at'] = null; $data['download_status'] = 'pending'; } } $wpdb->update( $this->db->media_table(), $data, array('id' => $existing->id) ); $skipped++; } else { $data['created_at'] = current_time('mysql'); $wpdb->insert($this->db->media_table(), $data); $stored++; } if ($progress_callback) { call_user_func($progress_callback, 'media_stored', array('media_key' => $media_key)); } } // Delete orphaned media records and their files if (!empty($received_keys)) { $placeholders = implode(',', array_fill(0, count($received_keys), '%s')); $values = array_merge(array($listing_key), $received_keys); $orphaned = $wpdb->get_results($wpdb->prepare( "SELECT id, local_path FROM {$this->db->media_table()} WHERE listing_key = %s AND media_key NOT IN ({$placeholders})", $values )); foreach ($orphaned as $record) { if ($record->local_path) { $filename = basename($record->local_path); $prefix = substr($listing_key, 0, 2); // Delete from both directories $paths_to_delete = array( $this->get_upload_dir() . '/' . $prefix . '/' . $listing_key . '/' . $filename, $this->get_persistent_upload_dir() . '/' . $prefix . '/' . $listing_key . '/' . $filename, ); // Also include WebP versions $webp_filename = preg_replace('/\.[^.]+$/', '.webp', $filename); $paths_to_delete[] = $this->get_upload_dir() . '/' . $prefix . '/' . $listing_key . '/' . $webp_filename; $paths_to_delete[] = $this->get_persistent_upload_dir() . '/' . $prefix . '/' . $listing_key . '/' . $webp_filename; foreach ($paths_to_delete as $path) { if (file_exists($path)) { unlink($path); } } } $wpdb->delete($this->db->media_table(), array('id' => $record->id)); } } // Update property's media_expires_at with earliest expiration from all media URLs $this->update_property_media_expiration($listing_key); return array('stored' => $stored, 'skipped' => $skipped); } /** * Download and cache all images for a HomeProz listing * * Called during sync to immediately cache images for HomeProz listings * so they're available even after the listing is sold and removed from MLS. * * @param string $listing_key Listing key * @param callable|null $progress_callback Progress callback * @param int $delay_seconds Delay between each image download (default 10s to respect API limits) * @return array Stats with 'downloaded', 'skipped', and 'errors' counts */ public function download_homeproz_images($listing_key, $progress_callback = null, $delay_seconds = 10) { global $wpdb; $stats = array('downloaded' => 0, 'skipped' => 0, 'errors' => 0); // Get all media records that haven't been downloaded yet $media_records = $wpdb->get_results($wpdb->prepare( "SELECT * FROM {$this->db->media_table()} WHERE listing_key = %s AND media_url IS NOT NULL AND download_status = 'pending' ORDER BY media_order ASC", $listing_key )); if (empty($media_records)) { return $stats; } $total_records = count($media_records); $current = 0; foreach ($media_records as $media) { $current++; // Check if already cached (check both directories) // Try known local_path first, then search by media_order pattern $found_file = null; if ($media->local_path) { $filename = basename($media->local_path); // Check for WebP version first $webp_filename = preg_replace('/\.[^.]+$/', '.webp', $filename); $found_file = $this->find_cached_file($listing_key, $webp_filename); // Check for original file if (!$found_file) { $found_file = $this->find_cached_file($listing_key, $filename); } } // If no local_path or file not found, search by media_order pattern if (!$found_file) { $extensions = array('webp', 'jpg', 'jpeg', 'png', 'gif'); foreach ($extensions as $ext) { $pattern_file = $media->media_order . '.' . $ext; $found_file = $this->find_cached_file($listing_key, $pattern_file); if ($found_file) { break; } } } // If file exists on disk, update database and skip download if ($found_file) { // Update database to reflect the cached file $prefix = substr($listing_key, 0, 2); $filename = basename($found_file['path']); $relative_path = $prefix . '/' . $listing_key . '/' . $filename; $wpdb->update( $this->db->media_table(), array( 'local_path' => $relative_path, 'local_url' => $found_file['url'], 'download_status' => 'completed', 'downloaded_at' => current_time('mysql'), ), array('id' => $media->id) ); $stats['skipped']++; if ($progress_callback) { call_user_func($progress_callback, 'media_skipped', array('media_key' => $media->media_key)); } continue; } // Download the image $url = $this->fetch_and_cache($media); if ($url) { $stats['downloaded']++; if ($progress_callback) { call_user_func($progress_callback, 'media_downloaded', array('media_key' => $media->media_key)); } // Rate limit: delay between image downloads to respect MLS API limits // Only delay if there are more images to download if ($delay_seconds > 0 && $current < $total_records) { sleep($delay_seconds); } } else { $stats['errors']++; if ($progress_callback) { call_user_func($progress_callback, 'media_error', array('media_key' => $media->media_key)); } } } return $stats; } /** * Update a property's media_expires_at field based on its media URLs * * Finds the earliest expiration timestamp from all media URLs * and sets it on the property record. * * @param string $listing_key Listing key */ public function update_property_media_expiration($listing_key) { global $wpdb; // Get all media URLs for this property $media_urls = $wpdb->get_col($wpdb->prepare( "SELECT media_url FROM {$this->db->media_table()} WHERE listing_key = %s AND media_url IS NOT NULL", $listing_key )); if (empty($media_urls)) { return; } // Find the earliest expiration timestamp $earliest_expires = null; foreach ($media_urls as $url) { $expires = $this->extract_url_expiration($url); if ($expires !== null) { if ($earliest_expires === null || $expires < $earliest_expires) { $earliest_expires = $expires; } } } // Update the property record if ($earliest_expires !== null) { $expires_at = gmdate('Y-m-d H:i:s', $earliest_expires); $wpdb->update( $this->db->properties_table(), array('media_expires_at' => $expires_at), array('listing_key' => $listing_key), array('%s'), array('%s') ); } } /** * Extract expiration timestamp from a media URL * * MLS Grid media URLs contain an 'expires' parameter with Unix timestamp. * * @param string $media_url The media URL * @return int|null Unix timestamp or null if not found */ public function extract_url_expiration($media_url) { if (empty($media_url)) { return null; } if (preg_match('/expires=(\d+)/', $media_url, $matches)) { return (int) $matches[1]; } return null; } /** * Get image URL for a media record, fetching on-demand if needed * * Checks both persistent (HomeProz) and standard cache directories. * * @param int|object $media Media ID or media record object * @param bool $fetch_if_missing Whether to fetch if not cached * @return string|null Local URL or null */ public function get_image_url($media, $fetch_if_missing = true) { global $wpdb; // Get media record if ID passed if (is_numeric($media)) { $media = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$this->db->media_table()} WHERE id = %d", $media )); } if (!$media) { return null; } // Check for cached file in both directories if ($media->local_path) { $filename = basename($media->local_path); // Check for WebP version first $webp_filename = preg_replace('/\.[^.]+$/', '.webp', $filename); $webp_found = $this->find_cached_file($media->listing_key, $webp_filename); if ($webp_found) { return $webp_found['url']; } // Check for original file $found = $this->find_cached_file($media->listing_key, $filename); if ($found) { return $found['url']; } } // Fetch on demand if ($fetch_if_missing && $media->media_url) { $result = $this->fetch_and_cache($media); // Propagate WP_Error (e.g., rate limiting) or return URL if (is_wp_error($result) || $result) { return $result; } } return null; } /** * Get primary image URL for a listing (on-demand) * * Checks both persistent (HomeProz) and standard cache directories. * * @param string $listing_key Listing key * @param bool $fetch_if_missing Whether to fetch if not cached * @return string|null Image URL */ public function get_primary_image($listing_key, $fetch_if_missing = true) { global $wpdb; // First check for already-cached image $cached = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$this->db->media_table()} WHERE listing_key = %s AND local_url IS NOT NULL AND local_path IS NOT NULL ORDER BY media_order ASC LIMIT 1", $listing_key )); if ($cached && $cached->local_path) { $filename = basename($cached->local_path); // Check for WebP version first $webp_filename = preg_replace('/\.[^.]+$/', '.webp', $filename); $webp_found = $this->find_cached_file($listing_key, $webp_filename); if ($webp_found) { return $webp_found['url']; } // Check for original file $found = $this->find_cached_file($listing_key, $filename); if ($found) { return $found['url']; } } // Get first media record (may not be cached) $media = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$this->db->media_table()} WHERE listing_key = %s AND media_url IS NOT NULL ORDER BY media_order ASC LIMIT 1", $listing_key )); if (!$media) { return null; } // Check for cached file in both directories if ($media->local_path) { $filename = basename($media->local_path); // Check for WebP version first $webp_filename = preg_replace('/\.[^.]+$/', '.webp', $filename); $webp_found = $this->find_cached_file($listing_key, $webp_filename); if ($webp_found) { return $webp_found['url']; } // Check for original file $found = $this->find_cached_file($listing_key, $filename); if ($found) { return $found['url']; } } // Fetch on demand if ($fetch_if_missing) { return $this->fetch_and_cache($media); } return null; } /** * Get all images for a listing (on-demand for first N) * * Checks both persistent (HomeProz) and standard cache directories. * * @param string $listing_key Listing key * @param int $fetch_limit Max images to fetch on-demand (0 = none) * @return array Media records with local_url populated where available */ public function get_listing_images($listing_key, $fetch_limit = 1) { global $wpdb; $media = $wpdb->get_results($wpdb->prepare( "SELECT * FROM {$this->db->media_table()} WHERE listing_key = %s ORDER BY media_order ASC", $listing_key )); if (empty($media)) { return array(); } $fetched = 0; foreach ($media as &$item) { // Check for cached file in both directories - prefer WebP version if ($item->local_path) { $filename = basename($item->local_path); // Check for WebP version first $webp_filename = preg_replace('/\.[^.]+$/', '.webp', $filename); $webp_found = $this->find_cached_file($listing_key, $webp_filename); if ($webp_found) { $item->local_url = $webp_found['url']; continue; } // Check for original file $found = $this->find_cached_file($listing_key, $filename); if ($found) { $item->local_url = $found['url']; continue; } } // Fetch on demand up to limit if ($fetched < $fetch_limit && $item->media_url) { $url = $this->fetch_and_cache($item); if ($url) { $item->local_url = $url; $fetched++; } } } return $media; } /** * Fetch image from MLS Grid and cache locally * * Uses MySQL advisory lock to ensure only one request downloads * a specific image at a time (prevents stampede on cold cache). * * Respects daily data budget - if approaching limit, will skip fetch * and return null (graceful degradation). * * @param object $media Media record * @return string|null Local URL on success, null on failure */ private function fetch_and_cache($media) { global $wpdb; if (empty($media->media_url)) { return null; } // Check daily data budget before fetching $rate_limiter = mls_plugin()->get_rate_limiter(); if (!$rate_limiter->can_fetch_image()) { $this->logger->warning('Daily data budget exhausted, skipping image fetch', array( 'listing_key' => $media->listing_key, 'media_key' => $media->media_key, 'remaining_bytes' => $rate_limiter->get_daily_data_remaining(), )); return null; } // Advisory lock key - unique per media record $lock_name = 'mls_media_' . $media->id; $lock_timeout = 35; // Slightly longer than HTTP timeout // Try to acquire lock (will wait up to $lock_timeout seconds) $lock_acquired = $wpdb->get_var($wpdb->prepare( "SELECT GET_LOCK(%s, %d)", $lock_name, $lock_timeout )); if ($lock_acquired !== '1') { $this->logger->warning('Could not acquire media lock', array( 'listing_key' => $media->listing_key, 'media_key' => $media->media_key, 'lock_result' => $lock_acquired, )); return null; } try { // Determine if this is a HomeProz listing (determines cache location) $is_homeproz = $this->is_homeproz_listing($media->listing_key); // Re-check if image was cached while we waited for lock $updated_media = $wpdb->get_row($wpdb->prepare( "SELECT local_path, local_url FROM {$this->db->media_table()} WHERE id = %d", $media->id )); // Check for existing file - first by local_path, then by media_order pattern $found_file = null; if ($updated_media && $updated_media->local_path) { // Check both cache directories for existing file $filename = basename($updated_media->local_path); // Check for WebP version first $webp_filename = preg_replace('/\.[^.]+$/', '.webp', $filename); $found_file = $this->find_cached_file($media->listing_key, $webp_filename); if (!$found_file) { $found_file = $this->find_cached_file($media->listing_key, $filename); } } // If no local_path or file not found, search by media_order pattern if (!$found_file) { $extensions = array('webp', 'jpg', 'jpeg', 'png', 'gif'); foreach ($extensions as $ext) { $pattern_file = $media->media_order . '.' . $ext; $found_file = $this->find_cached_file($media->listing_key, $pattern_file); if ($found_file) { break; } } } // If file exists on disk, update database and return URL if ($found_file) { $prefix = substr($media->listing_key, 0, 2); $filename = basename($found_file['path']); $relative_path = $prefix . '/' . $media->listing_key . '/' . $filename; $wpdb->update( $this->db->media_table(), array( 'local_path' => $relative_path, 'local_url' => $found_file['url'], 'download_status' => 'completed', 'downloaded_at' => current_time('mysql'), ), array('id' => $media->id) ); return $found_file['url']; } // If the media URL has expired, refresh property URLs from the API. // Use a property-level lock so concurrent fetches share one refresh. if ($this->is_url_expired($media->media_url)) { $refresh_lock = 'mls_url_refresh_' . $media->listing_key; $refresh_acquired = $wpdb->get_var($wpdb->prepare( "SELECT GET_LOCK(%s, %d)", $refresh_lock, 15 )); try { if ($refresh_acquired === '1') { // Re-check after lock — another process may have refreshed $latest = $wpdb->get_row($wpdb->prepare( "SELECT media_url FROM {$this->db->media_table()} WHERE id = %d", $media->id )); if (!$latest || $this->is_url_expired($latest->media_url)) { $this->refresh_media_urls($media->listing_key); } } $media = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$this->db->media_table()} WHERE id = %d", $media->id )); } finally { if ($refresh_acquired === '1') { $wpdb->query($wpdb->prepare("SELECT RELEASE_LOCK(%s)", $refresh_lock)); } } if (!$media || empty($media->media_url) || $this->is_url_expired($media->media_url)) { return null; } } // Download the image $request_args = array( 'timeout' => 30, ); // Allow skipping SSL verification if configured (for expired certs) if (defined('MLS_SKIP_SSL_VERIFY') && MLS_SKIP_SSL_VERIFY) { $request_args['sslverify'] = false; } $response = wp_remote_get($media->media_url, $request_args); if (is_wp_error($response)) { $this->logger->warning('Media fetch failed', array( 'listing_key' => $media->listing_key, 'media_key' => $media->media_key, 'error' => $response->get_error_message(), )); return null; } $status_code = wp_remote_retrieve_response_code($response); if ($status_code !== 200) { $this->logger->warning('Media fetch HTTP error', array( 'listing_key' => $media->listing_key, 'media_key' => $media->media_key, 'status' => $status_code, )); // Return error code for rate limiting so caller can handle appropriately if ($status_code === 429) { return new WP_Error('rate_limited', 'MLS Grid rate limit exceeded', array('status' => 429)); } return null; } $body = wp_remote_retrieve_body($response); if (empty($body)) { return null; } // Record bytes downloaded against daily data cap $bytes_downloaded = strlen($body); $rate_limiter->record_data_transfer($bytes_downloaded); // Determine extension $content_type = wp_remote_retrieve_header($response, 'content-type'); $extension = $this->get_extension_from_content_type($content_type, $media->media_url); // Create directory (HomeProz listings go to persistent cache) $listing_dir = $this->get_listing_dir($media->listing_key, $is_homeproz); if (!file_exists($listing_dir)) { wp_mkdir_p($listing_dir); } // Save file $filename = $media->media_order . '.' . $extension; $file_path = $listing_dir . '/' . $filename; if (file_put_contents($file_path, $body) === false) { $this->logger->error('Failed to write media file', array( 'path' => $file_path, )); return null; } // Convert to WebP if PNG or file is large (>500KB) $conversion = $this->maybe_convert_to_webp($file_path, $extension); if ($conversion['converted']) { $file_path = $conversion['path']; $extension = $conversion['extension']; $filename = $media->media_order . '.' . $extension; $content_type = 'image/webp'; } // Update database with correct URL based on cache location $prefix = substr($media->listing_key, 0, 2); $relative_path = $prefix . '/' . $media->listing_key . '/' . $filename; $base_url = $is_homeproz ? $this->get_persistent_upload_url() : $this->get_upload_url(); $local_url = $base_url . '/' . $relative_path; // Get actual file size after any conversion $final_size = filesize($file_path); $wpdb->update( $this->db->media_table(), array( 'local_path' => $relative_path, 'local_url' => $local_url, 'file_size' => $final_size, 'mime_type' => $content_type, 'downloaded_at' => current_time('mysql'), 'download_status' => 'completed', ), array('id' => $media->id) ); $this->logger->debug('Media fetched and cached', array( 'listing_key' => $media->listing_key, 'media_key' => $media->media_key, 'bytes_downloaded' => $bytes_downloaded, 'final_size' => $final_size, 'converted' => $conversion['converted'], )); return $local_url; } finally { // Always release the lock $wpdb->query($wpdb->prepare("SELECT RELEASE_LOCK(%s)", $lock_name)); } } /** * Get file extension from content type * * @param string $content_type Content type header * @param string $url Original URL as fallback * @return string File extension */ private function get_extension_from_content_type($content_type, $url) { $content_type = strtolower(explode(';', $content_type)[0]); $map = array( 'image/jpeg' => 'jpg', 'image/jpg' => 'jpg', 'image/png' => 'png', 'image/gif' => 'gif', 'image/webp' => 'webp', ); if (isset($map[$content_type])) { return $map[$content_type]; } // Fallback to URL extension $path = parse_url($url, PHP_URL_PATH); $ext = pathinfo($path, PATHINFO_EXTENSION); return $ext ?: 'jpg'; } /** * Delete all media for a property * * Deletes from both persistent and standard cache directories. * * @param string $listing_key Listing key */ public function delete_property_media($listing_key) { global $wpdb; $prefix = substr($listing_key, 0, 2); // Delete from persistent cache directory $persistent_dir = $this->get_persistent_upload_dir() . '/' . $prefix . '/' . $listing_key; if (file_exists($persistent_dir)) { $this->recursive_delete($persistent_dir); } // Delete from standard cache directory $standard_dir = $this->get_upload_dir() . '/' . $prefix . '/' . $listing_key; if (file_exists($standard_dir)) { $this->recursive_delete($standard_dir); } // Delete records $wpdb->delete( $this->db->media_table(), array('listing_key' => $listing_key) ); } /** * Recursively delete a directory * * @param string $dir Directory path */ private function recursive_delete($dir) { if (!is_dir($dir)) { return; } $files = array_diff(scandir($dir), array('.', '..')); foreach ($files as $file) { $path = $dir . '/' . $file; if (is_dir($path)) { $this->recursive_delete($path); } else { unlink($path); } } rmdir($dir); } /** * Get media for a listing (legacy compatibility) * * @param string $listing_key Listing key * @return array Media records */ public function get_listing_media($listing_key) { global $wpdb; return $wpdb->get_results($wpdb->prepare( "SELECT * FROM {$this->db->media_table()} WHERE listing_key = %s ORDER BY media_order ASC", $listing_key )); } /** * Check if a media URL has expired * * MLS Grid media URLs contain an 'expires' parameter with Unix timestamp. * Returns true if the URL is expired or will expire within the buffer time. * * @param string $media_url The media URL to check * @param int $buffer_seconds Buffer time before actual expiration (default: 300 = 5 min) * @return bool True if expired or expiring soon */ public function is_url_expired($media_url, $buffer_seconds = 300) { if (empty($media_url)) { return true; } // Extract expires parameter from URL if (preg_match('/expires=(\d+)/', $media_url, $matches)) { $expires = (int) $matches[1]; return (time() + $buffer_seconds) >= $expires; } // If no expires param found, assume expired to be safe return true; } /** * Refresh media URLs for a listing from the API * * Fetches fresh media data from MLS Grid and updates the database. * This is used when cached URLs have expired. * * Note: MLS Grid API only allows filtering by ListingId, not ListingKey. * This method looks up the listing_id from the local database first. * * @param string $listing_key Listing key * @return bool True on success, false on failure */ public function refresh_media_urls($listing_key) { global $wpdb; $plugin = mls_plugin(); $api_client = $plugin->get_api_client(); // Look up listing_id from database (MLS Grid API requires ListingId for filtering) $listing_id = $wpdb->get_var($wpdb->prepare( "SELECT listing_id FROM {$this->db->properties_table()} WHERE listing_key = %s", $listing_key )); if (!$listing_id) { $this->logger->warning('Cannot refresh media: listing_id not found', array( 'listing_key' => $listing_key, )); return false; } // Fetch property with media from API using ListingId $property = $api_client->get_property_media($listing_id); if (is_wp_error($property) || !$property) { $this->logger->warning('Failed to refresh media URLs', array( 'listing_key' => $listing_key, 'listing_id' => $listing_id, 'error' => is_wp_error($property) ? $property->get_error_message() : 'Property not found', )); return false; } // Update media records with fresh URLs if (isset($property['Media']) && is_array($property['Media'])) { $this->sync_property_media($listing_key, $property['Media']); $this->logger->debug('Refreshed media URLs', array( 'listing_key' => $listing_key, 'media_count' => count($property['Media']), )); return true; } return false; } /** * Clean up orphaned media files (files without database records) * * Checks both standard and persistent cache directories. * * @return int Number of directories deleted */ public function cleanup_orphaned_files() { global $wpdb; $deleted = 0; // Check both cache directories $directories = array( $this->get_upload_dir(), $this->get_persistent_upload_dir(), ); foreach ($directories as $base_dir) { if (!is_dir($base_dir)) { continue; } foreach (scandir($base_dir) as $prefix) { if ($prefix === '.' || $prefix === '..' || !is_dir($base_dir . '/' . $prefix)) { continue; } $prefix_dir = $base_dir . '/' . $prefix; foreach (scandir($prefix_dir) as $listing_key) { if ($listing_key === '.' || $listing_key === '..') { continue; } $listing_dir = $prefix_dir . '/' . $listing_key; if (!is_dir($listing_dir)) { continue; } $exists = $wpdb->get_var($wpdb->prepare( "SELECT COUNT(*) FROM {$this->db->properties_table()} WHERE listing_key = %s", $listing_key )); if (!$exists) { $this->recursive_delete($listing_dir); $deleted++; } } } } return $deleted; } /** * Get cache statistics * * @return array Cache stats */ public function get_cache_stats() { global $wpdb; return array( 'total_media' => (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$this->db->media_table()}" ), 'cached' => (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$this->db->media_table()} WHERE local_url IS NOT NULL" ), 'uncached' => (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$this->db->media_table()} WHERE local_url IS NULL" ), ); } /** * Get path to missing media log file (legacy compatibility) * * @return string File path */ public function get_missing_log_path() { $upload_dir = wp_upload_dir(); return $upload_dir['basedir'] . '/mls-missing-media.log'; } /** * Get missing media count (legacy compatibility) * * @return int */ public function get_missing_count() { $log_file = $this->get_missing_log_path(); if (!file_exists($log_file)) { return 0; } $content = file_get_contents($log_file); return substr_count($content, "\n"); } /** * Clear missing log (legacy compatibility) */ public function clear_missing_log() { $log_file = $this->get_missing_log_path(); if (file_exists($log_file)) { unlink($log_file); } } }