db = $db; $this->logger = $logger; } /** * Get base upload directory for MLS media * * @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 * * @return string URL */ public function get_upload_url() { $upload_dir = wp_upload_dir(); return $upload_dir['baseurl'] . '/' . self::UPLOAD_SUBDIR; } /** * Get storage directory for a specific listing * * @param string $listing_key Listing key * @return string Absolute path */ public function get_listing_dir($listing_key) { // Use first 2 characters as subdirectory to prevent too many files in one folder $prefix = substr($listing_key, 0, 2); return $this->get_upload_dir() . '/' . $prefix . '/' . $listing_key; } /** * Sync media for a property * * @param string $listing_key Listing key * @param array $media_array Media array from API * @param bool $force Force re-download all media * @param callable|null $progress_callback Callback for progress updates */ public function sync_property_media($listing_key, $media_array, $force = false, $progress_callback = null) { global $wpdb; if (empty($media_array)) { return; } $received_keys = array(); 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) { // Update existing record $wpdb->update( $this->db->media_table(), $data, array('id' => $existing->id) ); // Check if we need to re-download if ($force || $this->needs_download($existing, $media)) { $result = $this->download_media($existing->id); if ($progress_callback) { if ($result) { call_user_func($progress_callback, 'media_downloaded', array('media_key' => $media_key)); } else { $error = $this->get_last_download_error($existing->id); call_user_func($progress_callback, 'media_error', array('media_key' => $media_key, 'error' => $error)); } } } else { if ($progress_callback) { call_user_func($progress_callback, 'media_skipped', array('media_key' => $media_key)); } } } else { // Insert new record $data['created_at'] = current_time('mysql'); $wpdb->insert($this->db->media_table(), $data); $new_id = $wpdb->insert_id; // Queue download $result = $this->download_media($new_id); if ($progress_callback) { if ($result) { call_user_func($progress_callback, 'media_downloaded', array('media_key' => $media_key)); } else { $error = $this->get_last_download_error($new_id); call_user_func($progress_callback, 'media_error', array('media_key' => $media_key, 'error' => $error)); } } } } // Delete media that no longer exists 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) { // Delete file if exists if ($record->local_path) { $file_path = $this->get_upload_dir() . '/' . $record->local_path; if (file_exists($file_path)) { unlink($file_path); } } // Delete record $wpdb->delete($this->db->media_table(), array('id' => $record->id)); } } } /** * Get the last download error for a media record * * @param int $media_id Media ID * @return string|null Error message */ private function get_last_download_error($media_id) { global $wpdb; return $wpdb->get_var($wpdb->prepare( "SELECT download_error FROM {$this->db->media_table()} WHERE id = %d", $media_id )); } /** * Get the path to the missing media log file * * @return string File path */ public function get_missing_log_path() { $upload_dir = wp_upload_dir(); return $upload_dir['basedir'] . '/mls-missing-media.log'; } /** * Log a failed media download to the missing media log file * * @param object $media Media record * @param string $error Error message */ private function log_missing_media($media, $error) { $log_file = $this->get_missing_log_path(); $timestamp = date('Y-m-d H:i:s'); $line = sprintf( "[%s] %s | %s | %s | %s\n", $timestamp, $media->listing_key, $media->media_key, $error, $media->media_url ); file_put_contents($log_file, $line, FILE_APPEND | LOCK_EX); } /** * Clear the missing media log file */ public function clear_missing_log() { $log_file = $this->get_missing_log_path(); if (file_exists($log_file)) { unlink($log_file); } } /** * Get missing media count from log file * * @return int Number of missing media entries */ 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"); } /** * Check if media needs to be downloaded * * @param object $existing Existing media record * @param array $new_data New media data from API * @return bool */ private function needs_download($existing, $new_data) { // No local file if (empty($existing->local_path)) { return true; } // File doesn't exist $file_path = $this->get_upload_dir() . '/' . $existing->local_path; if (!file_exists($file_path)) { return true; } // Media URL changed if ($existing->media_url !== ($new_data['MediaURL'] ?? null)) { return true; } return false; } /** * Download a media file * * @param int $media_id Media record ID * @return bool Success */ public function download_media($media_id) { global $wpdb; $media = $wpdb->get_row($wpdb->prepare( "SELECT * FROM {$this->db->media_table()} WHERE id = %d", $media_id )); if (!$media || empty($media->media_url)) { return false; } // Increment attempt counter $wpdb->update( $this->db->media_table(), array('download_attempts' => $media->download_attempts + 1), array('id' => $media_id) ); // Download with exponential backoff for rate limits $max_retries = 5; $response = null; $status_code = 0; $base_delay = 1; // Start with 1 second for ($retry = 0; $retry < $max_retries; $retry++) { // Exponential backoff: 1s, 2s, 4s, 8s, 16s if ($retry > 0) { $delay = $base_delay * pow(2, $retry - 1); $this->logger->debug('Media download retry', array( 'media_id' => $media_id, 'retry' => $retry, 'delay' => $delay, )); sleep($delay); } $response = wp_remote_get($media->media_url, array( 'timeout' => 60, 'stream' => false, )); if (is_wp_error($response)) { $error_msg = $response->get_error_message(); $this->logger->warning('Media download failed', array( 'media_id' => $media_id, 'error' => $error_msg, 'retry' => $retry, )); if ($retry === $max_retries - 1) { $wpdb->update( $this->db->media_table(), array('download_error' => $error_msg), array('id' => $media_id) ); $this->log_missing_media($media, $error_msg); return false; } continue; } $status_code = wp_remote_retrieve_response_code($response); // Success if ($status_code === 200) { break; } // Retryable errors: 429 (rate limit), 500, 502, 503, 504 (server errors) $retryable = in_array($status_code, array(429, 500, 502, 503, 504)); if ($retryable && $retry < $max_retries - 1) { $this->logger->debug('Media download retryable error', array( 'media_id' => $media_id, 'status_code' => $status_code, 'retry' => $retry, )); continue; } // Non-retryable or exhausted retries - record and fail $error_msg = "HTTP {$status_code}"; $wpdb->update( $this->db->media_table(), array('download_error' => $error_msg), array('id' => $media_id) ); $this->log_missing_media($media, $error_msg); return false; } if ($status_code !== 200) { $error_msg = "HTTP {$status_code}"; $wpdb->update( $this->db->media_table(), array('download_error' => $error_msg), array('id' => $media_id) ); $this->log_missing_media($media, $error_msg); return false; } $body = wp_remote_retrieve_body($response); if (empty($body)) { $wpdb->update( $this->db->media_table(), array('download_error' => 'Empty response'), array('id' => $media_id) ); return false; } // Determine file extension from content type or URL $content_type = wp_remote_retrieve_header($response, 'content-type'); $extension = $this->get_extension_from_content_type($content_type, $media->media_url); // Create directory $listing_dir = $this->get_listing_dir($media->listing_key); 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) { $wpdb->update( $this->db->media_table(), array('download_error' => 'Failed to write file'), array('id' => $media_id) ); return false; } // Calculate relative path $prefix = substr($media->listing_key, 0, 2); $relative_path = $prefix . '/' . $media->listing_key . '/' . $filename; $local_url = $this->get_upload_url() . '/' . $relative_path; // Update record $wpdb->update( $this->db->media_table(), array( 'local_path' => $relative_path, 'local_url' => $local_url, 'file_size' => strlen($body), 'mime_type' => $content_type, 'downloaded_at' => current_time('mysql'), 'download_error' => null, ), array('id' => $media_id) ); return true; } /** * 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) { // Extract main type from content-type header $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 * * @param string $listing_key Listing key */ public function delete_property_media($listing_key) { global $wpdb; // Delete files $listing_dir = $this->get_listing_dir($listing_key); if (file_exists($listing_dir)) { $this->recursive_delete($listing_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 * * @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 )); } /** * Get primary image URL for a listing * * @param string $listing_key Listing key * @return string|null Image URL */ public function get_primary_image($listing_key) { global $wpdb; $media = $wpdb->get_row($wpdb->prepare( "SELECT local_url, media_url FROM {$this->db->media_table()} WHERE listing_key = %s AND local_path IS NOT NULL ORDER BY media_order ASC LIMIT 1", $listing_key )); if ($media && $media->local_url) { return $media->local_url; } return null; } /** * Download pending media (for batch processing) * * @param int $limit Max media to download * @param callable|null $progress_callback Callback for progress updates * @return array Stats */ public function download_pending($limit = 100, $progress_callback = null) { global $wpdb; $pending = $wpdb->get_results($wpdb->prepare( "SELECT id, media_key FROM {$this->db->media_table()} WHERE local_path IS NULL AND media_url IS NOT NULL AND download_attempts < 3 LIMIT %d", $limit )); $stats = array( 'total' => count($pending), 'success' => 0, 'failed' => 0, ); foreach ($pending as $media) { if ($this->download_media($media->id)) { $stats['success']++; if ($progress_callback) { call_user_func($progress_callback, 'media_downloaded', array('media_key' => $media->media_key)); } } else { $stats['failed']++; if ($progress_callback) { call_user_func($progress_callback, 'media_error', array('media_key' => $media->media_key)); } } } return $stats; } /** * Clean up orphaned media (files without database records) * * @return int Number of files deleted */ public function cleanup_orphaned_files() { $deleted = 0; $base_dir = $this->get_upload_dir(); if (!is_dir($base_dir)) { return 0; } // Iterate through prefix directories foreach (scandir($base_dir) as $prefix) { if ($prefix === '.' || $prefix === '..' || !is_dir($base_dir . '/' . $prefix)) { continue; } $prefix_dir = $base_dir . '/' . $prefix; // Iterate through listing directories 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; } // Check if listing exists in database global $wpdb; $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; } }