c2d5b2248d
Image handling improvements: - Convert PNG and images >500KB to WebP format - Resize images wider than 1600px maintaining aspect ratio - Check for .webp version before falling back to original - WebP quality set to 80 (equivalent to JPEG 90%) Garbage collection for disk space management: - New MLS_Garbage_Collector class runs after each sync - Only active when MLS_GC_DISK_THRESHOLD defined in wp-config - Deletes image directories older than 24 hours, oldest first - Stops when free space reaches 5GB or 2GB deleted per run - Protects recently accessed images from deletion Documentation: - Added Garbage Collection section to README - Updated Features list and File Structure Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
888 lines
29 KiB
PHP
Executable File
888 lines
29 KiB
PHP
Executable File
<?php
|
|
/**
|
|
* MLS Media Handler
|
|
*
|
|
* Handles on-demand fetching and caching of media files from MLS listings.
|
|
* Images are downloaded when first requested and cached locally.
|
|
*
|
|
* Per MLS Grid rules:
|
|
* - MediaURLs must NOT be used directly on websites
|
|
* - Images must be downloaded and served from our own server
|
|
*/
|
|
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* 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
|
|
*
|
|
* @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) {
|
|
$prefix = substr($listing_key, 0, 2);
|
|
return $this->get_upload_dir() . '/' . $prefix . '/' . $listing_key;
|
|
}
|
|
|
|
/**
|
|
* 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 - if so, clear cached file
|
|
if ($existing->media_url !== ($media['MediaURL'] ?? null) && $existing->local_path) {
|
|
$file_path = $this->get_upload_dir() . '/' . $existing->local_path;
|
|
if (file_exists($file_path)) {
|
|
unlink($file_path);
|
|
}
|
|
$data['local_path'] = null;
|
|
$data['local_url'] = null;
|
|
$data['downloaded_at'] = null;
|
|
}
|
|
|
|
$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
|
|
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) {
|
|
$file_path = $this->get_upload_dir() . '/' . $record->local_path;
|
|
if (file_exists($file_path)) {
|
|
unlink($file_path);
|
|
}
|
|
}
|
|
$wpdb->delete($this->db->media_table(), array('id' => $record->id));
|
|
}
|
|
}
|
|
|
|
return array('stored' => $stored, 'skipped' => $skipped);
|
|
}
|
|
|
|
/**
|
|
* Get image URL for a media record, fetching on-demand if needed
|
|
*
|
|
* @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;
|
|
}
|
|
|
|
// Already cached - check for WebP version first
|
|
if ($media->local_url && $media->local_path) {
|
|
$file_path = $this->get_upload_dir() . '/' . $media->local_path;
|
|
$actual_path = $this->prefer_webp_path($file_path);
|
|
if (file_exists($actual_path)) {
|
|
// If WebP version exists, return WebP URL
|
|
if ($actual_path !== $file_path) {
|
|
$webp_path = preg_replace('/\.[^.]+$/', '.webp', $media->local_path);
|
|
return $this->get_upload_url() . '/' . $webp_path;
|
|
}
|
|
return $media->local_url;
|
|
}
|
|
}
|
|
|
|
// Fetch on demand
|
|
if ($fetch_if_missing && $media->media_url) {
|
|
$result = $this->fetch_and_cache($media);
|
|
if ($result) {
|
|
return $result;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Get primary image URL for a listing (on-demand)
|
|
*
|
|
* @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) {
|
|
$file_path = $this->get_upload_dir() . '/' . $cached->local_path;
|
|
$actual_path = $this->prefer_webp_path($file_path);
|
|
if (file_exists($actual_path)) {
|
|
// If WebP version exists, return WebP URL
|
|
if ($actual_path !== $file_path) {
|
|
$webp_path = preg_replace('/\.[^.]+$/', '.webp', $cached->local_path);
|
|
return $this->get_upload_url() . '/' . $webp_path;
|
|
}
|
|
return $cached->local_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;
|
|
}
|
|
|
|
// If already cached and file exists, return it - check for WebP first
|
|
if ($media->local_url && $media->local_path) {
|
|
$file_path = $this->get_upload_dir() . '/' . $media->local_path;
|
|
$actual_path = $this->prefer_webp_path($file_path);
|
|
if (file_exists($actual_path)) {
|
|
// If WebP version exists, return WebP URL
|
|
if ($actual_path !== $file_path) {
|
|
$webp_path = preg_replace('/\.[^.]+$/', '.webp', $media->local_path);
|
|
return $this->get_upload_url() . '/' . $webp_path;
|
|
}
|
|
return $media->local_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)
|
|
*
|
|
* @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 if cached and file exists - prefer WebP version
|
|
if ($item->local_url && $item->local_path) {
|
|
$file_path = $this->get_upload_dir() . '/' . $item->local_path;
|
|
$actual_path = $this->prefer_webp_path($file_path);
|
|
if (file_exists($actual_path)) {
|
|
// If WebP version exists, update the URL
|
|
if ($actual_path !== $file_path) {
|
|
$webp_path = preg_replace('/\.[^.]+$/', '.webp', $item->local_path);
|
|
$item->local_url = $this->get_upload_url() . '/' . $webp_path;
|
|
}
|
|
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).
|
|
*
|
|
* @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;
|
|
}
|
|
|
|
// 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 {
|
|
// 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
|
|
));
|
|
|
|
if ($updated_media && $updated_media->local_path) {
|
|
$file_path = $this->get_upload_dir() . '/' . $updated_media->local_path;
|
|
$actual_path = $this->prefer_webp_path($file_path);
|
|
if (file_exists($actual_path)) {
|
|
// Another request cached it while we waited
|
|
// If WebP version exists, return WebP URL
|
|
if ($actual_path !== $file_path) {
|
|
$webp_path = preg_replace('/\.[^.]+$/', '.webp', $updated_media->local_path);
|
|
return $this->get_upload_url() . '/' . $webp_path;
|
|
}
|
|
return $updated_media->local_url;
|
|
}
|
|
}
|
|
|
|
// Download the image
|
|
$response = wp_remote_get($media->media_url, array(
|
|
'timeout' => 30,
|
|
));
|
|
|
|
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 null;
|
|
}
|
|
|
|
$body = wp_remote_retrieve_body($response);
|
|
if (empty($body)) {
|
|
return null;
|
|
}
|
|
|
|
// 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
|
|
$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) {
|
|
$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
|
|
$prefix = substr($media->listing_key, 0, 2);
|
|
$relative_path = $prefix . '/' . $media->listing_key . '/' . $filename;
|
|
$local_url = $this->get_upload_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'),
|
|
),
|
|
array('id' => $media->id)
|
|
);
|
|
|
|
$this->logger->debug('Media fetched and cached', array(
|
|
'listing_key' => $media->listing_key,
|
|
'media_key' => $media->media_key,
|
|
'original_size' => strlen($body),
|
|
'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
|
|
*
|
|
* @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 (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)
|
|
*
|
|
* @return int Number of directories deleted
|
|
*/
|
|
public function cleanup_orphaned_files() {
|
|
global $wpdb;
|
|
|
|
$deleted = 0;
|
|
$base_dir = $this->get_upload_dir();
|
|
|
|
if (!is_dir($base_dir)) {
|
|
return 0;
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|