Files
homeproz/wp-content/plugins/mls-by-hansonxyz/includes/class-mls-media-handler.php
T
Hanson.xyz Dev 5e4ebfb99e Add verbose mode, progress indicators, and missing media log
- Add --verbose flag to sync commands for detailed API request/response output
- Add progress indicators (.=#xPpE|) for compact sync output
- Implement exponential backoff (1s, 2s, 4s, 8s, 16s) for media downloads
- Log failed media downloads to wp-content/uploads/mls-missing-media.log
- Add 'wp mls cache missing' command to view/clear the log
- Retry on rate limit (429) and server errors (5xx)
- Update documentation with new features

Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-14 22:20:41 -06:00

650 lines
20 KiB
PHP

<?php
/**
* MLS Media Handler
*
* Handles downloading and managing media files from MLS listings
*/
if (!defined('ABSPATH')) {
exit;
}
class MLS_Media_Handler {
/**
* Upload subdirectory for MLS media
*/
const UPLOAD_SUBDIR = 'mls-listings';
/**
* Database instance
*/
private $db;
/**
* Logger instance
*/
private $logger;
/**
* Constructor
*/
public function __construct(MLS_DB $db, MLS_Logger $logger) {
$this->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;
}
}