PluginProbe
Search Atlas SEO – OTTO AI SEO Automation for WordPress / 2.6.15
Search Atlas SEO – OTTO AI SEO Automation for WordPress v2.6.15
2.6.26 2.6.25 2.6.24 2.6.23 2.6.22 2.6.21 2.6.20 2.6.19 2.6.18 2.6.17 2.6.16 2.6.15 2.6.14 2.6.13 2.6.12 2.6.11 2.6.10 2.6.9 2.6.8 2.6.7 2.6.6 2.6.5 2.6.4 2.6.3 2.5.23 All 138 releases
metasync / media-optimization / class-media-batch-optimizer.php

class-media-batch-optimizer.php in Search Atlas SEO – OTTO AI SEO Automation for WordPress 2.6.15, at media-optimization/class-media-batch-optimizer.php

366 lines 12.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Metasync_Media_Batch_Optimizer
4 * AJAX-driven batch optimizer for converting existing media library images.
5 * Uses browser-driven AJAX chaining for speed, with WP Cron as fallback.
6 *
7 * @package Search Atlas SEO
8 * @copyright Copyright (C) 2021-2025, Search Atlas Group - support@searchatlas.com
9 * @since 2.6.0
10 */
11
12 if (!defined('ABSPATH')) {
13 exit;
14 }
15
16 class Metasync_Media_Batch_Optimizer {
17
18 private const QUEUE_OPTION = 'metasync_batch_optimize_queue';
19 private const PROGRESS_OPTION = 'metasync_batch_optimize_progress';
20 private const CRON_HOOK = 'metasync_media_batch_optimize_cron';
21 private const DEFAULT_BATCH_SIZE = 10;
22 private const MIN_BATCH_SIZE = 2;
23 private const MAX_FILTER_BATCH_SIZE = 50;
24 private const CRON_TIME_LIMIT = 30; // seconds per cron tick
25 private const TIME_SAFETY_MARGIN = 5; // seconds reserved for saving progress
26 private const QUERY_PAGE_SIZE = 1000;
27
28 /**
29 * Memory per image estimate in bytes (30 MB).
30 * Used to calculate how many images can be processed safely.
31 */
32 private const MEMORY_PER_IMAGE = 30 * 1024 * 1024;
33
34 /**
35 * Calculate adaptive batch size based on available memory.
36 *
37 * Uses PHP memory_limit and current usage to determine a safe batch size.
38 * Filterable via 'metasync_batch_optimize_size' for manual override.
39 *
40 * @return int Batch size (clamped between MIN_BATCH_SIZE and DEFAULT_BATCH_SIZE).
41 */
42 public static function get_batch_size(): int {
43 $filtered = apply_filters('metasync_batch_optimize_size', 0);
44 if ($filtered > 0) {
45 return max(self::MIN_BATCH_SIZE, min(self::MAX_FILTER_BATCH_SIZE, (int) $filtered));
46 }
47
48 return static::calculate_batch_size(
49 (string) ini_get('memory_limit'),
50 memory_get_usage(true)
51 );
52 }
53
54 /**
55 * Calculate a safe time limit for cron processing.
56 *
57 * Respects PHP's max_execution_time so the process can save progress
58 * before being killed. Leaves TIME_SAFETY_MARGIN seconds for cleanup.
59 *
60 * @param int $max_execution_time PHP max_execution_time (0 = unlimited). Accepts parameter for testability.
61 * @return int Safe time limit in seconds (minimum 1).
62 */
63 protected static function get_safe_time_limit(int $max_execution_time = -1): int {
64 if ($max_execution_time < 0) {
65 $max_execution_time = (int) ini_get('max_execution_time');
66 }
67
68 // 0 means no limit — use the configured constant.
69 if ($max_execution_time === 0) {
70 return self::CRON_TIME_LIMIT;
71 }
72
73 $safe = $max_execution_time - self::TIME_SAFETY_MARGIN;
74
75 return max(1, min(self::CRON_TIME_LIMIT, $safe));
76 }
77
78 /**
79 * Pure calculation of batch size from memory parameters.
80 *
81 * @param string $memory_limit PHP memory_limit value (e.g. '128M', '-1').
82 * @param int $memory_usage Current memory usage in bytes.
83 * @return int Batch size (clamped between MIN_BATCH_SIZE and DEFAULT_BATCH_SIZE).
84 */
85 protected static function calculate_batch_size(string $memory_limit, int $memory_usage): int {
86 // Unlimited or unreadable memory — use default.
87 if ($memory_limit === '-1' || $memory_limit === '') {
88 return self::DEFAULT_BATCH_SIZE;
89 }
90
91 $memory_limit = trim($memory_limit);
92 if ($memory_limit === '0') {
93 return self::MIN_BATCH_SIZE;
94 }
95
96 $limit_bytes = (int) $memory_limit;
97 $unit = strtolower(substr($memory_limit, -1));
98 $limit_bytes = match ($unit) {
99 'g' => $limit_bytes * 1024 * 1024 * 1024,
100 'm' => $limit_bytes * 1024 * 1024,
101 'k' => $limit_bytes * 1024,
102 default => $limit_bytes,
103 };
104
105 $available = $limit_bytes - $memory_usage;
106
107 if ($available <= 0) {
108 return self::MIN_BATCH_SIZE;
109 }
110
111 $safe_count = (int) floor($available / self::MEMORY_PER_IMAGE);
112
113 return max(self::MIN_BATCH_SIZE, min(self::DEFAULT_BATCH_SIZE, $safe_count));
114 }
115
116 /**
117 * Start a new batch optimization run.
118 *
119 * @param array $settings Media optimization settings.
120 * @return array Progress data.
121 */
122 public static function start_batch(array $settings): array {
123 if (self::is_running()) {
124 return self::get_progress();
125 }
126
127 $ids = self::query_unoptimized_ids();
128
129 if (empty($ids)) {
130 return [
131 'total' => 0,
132 'processed' => 0,
133 'failed' => 0,
134 'status' => 'completed',
135 'started_at' => current_time('mysql'),
136 ];
137 }
138
139 update_option(self::QUEUE_OPTION, $ids, false);
140
141 $progress = [
142 'total' => count($ids),
143 'processed' => 0,
144 'failed' => 0,
145 'status' => 'running',
146 'started_at' => current_time('mysql'),
147 ];
148 update_option(self::PROGRESS_OPTION, $progress, false);
149
150 // Store settings for cron fallback to use
151 update_option('metasync_batch_optimize_settings', $settings, false);
152
153 // Schedule cron as fallback (runs if browser tab is closed)
154 if (!wp_next_scheduled(self::CRON_HOOK)) {
155 wp_schedule_event(time() + 120, 'metasync_every_2_minutes', self::CRON_HOOK);
156 }
157
158 return self::get_progress();
159 }
160
161 /**
162 * Cancel a running batch optimization.
163 */
164 public static function cancel_batch(): void {
165 $progress = self::get_progress();
166 $progress['status'] = 'cancelled';
167 update_option(self::PROGRESS_OPTION, $progress, false);
168
169 delete_option(self::QUEUE_OPTION);
170 wp_clear_scheduled_hook(self::CRON_HOOK);
171 }
172
173 /**
174 * Get current batch progress.
175 *
176 * @return array Progress data.
177 */
178 public static function get_progress(): array {
179 $default = [
180 'total' => 0,
181 'processed' => 0,
182 'failed' => 0,
183 'status' => 'idle',
184 'started_at' => '',
185 ];
186
187 return wp_parse_args(get_option(self::PROGRESS_OPTION, []), $default);
188 }
189
190 /**
191 * Check if a batch is currently running.
192 */
193 public static function is_running(): bool {
194 $progress = self::get_progress();
195 return $progress['status'] === 'running';
196 }
197
198 /**
199 * Process one batch via AJAX (browser-driven chaining).
200 * Processes one adaptive batch of images and returns updated progress immediately.
201 *
202 * @return array Updated progress data.
203 */
204 public static function process_ajax_tick(): array {
205 $progress = self::get_progress();
206
207 if ($progress['status'] !== 'running') {
208 return $progress;
209 }
210
211 $queue = get_option(self::QUEUE_OPTION, []);
212
213 if (empty($queue)) {
214 self::complete_batch();
215 return self::get_progress();
216 }
217
218 $settings = get_option('metasync_batch_optimize_settings', []);
219 $batch = array_splice($queue, 0, self::get_batch_size());
220
221 foreach ($batch as $attachment_id) {
222 // Re-check status in case cancel was triggered mid-batch
223 $current = get_option(self::PROGRESS_OPTION, []);
224 if (($current['status'] ?? '') !== 'running') {
225 break;
226 }
227
228 self::convert_single($attachment_id, $settings, $progress);
229 }
230
231 update_option(self::QUEUE_OPTION, $queue, false);
232 update_option(self::PROGRESS_OPTION, $progress, false);
233
234 if (empty($queue)) {
235 self::complete_batch();
236 return self::get_progress();
237 }
238
239 return $progress;
240 }
241
242 /**
243 * Process batch tick via WP Cron (fallback when browser tab is closed).
244 * Runs a time-limited loop to process as many images as possible within CRON_TIME_LIMIT seconds.
245 */
246 public static function process_batch_tick(): void {
247 $progress = self::get_progress();
248
249 if ($progress['status'] !== 'running') {
250 self::complete_batch();
251 return;
252 }
253
254 $queue = get_option(self::QUEUE_OPTION, []);
255
256 if (empty($queue)) {
257 self::complete_batch();
258 return;
259 }
260
261 $settings = get_option('metasync_batch_optimize_settings', []);
262 $start = time();
263 $time_limit = self::get_safe_time_limit();
264
265 // Process images until time limit or queue empty.
266 // Batch size is re-computed each iteration so it adapts as memory fills up.
267 while (!empty($queue) && (time() - $start) < $time_limit) {
268 $batch = array_splice($queue, 0, self::get_batch_size());
269
270 foreach ($batch as $attachment_id) {
271 self::convert_single($attachment_id, $settings, $progress);
272 }
273
274 // Save progress after each batch (in case of crash)
275 update_option(self::PROGRESS_OPTION, $progress, false);
276 }
277
278 update_option(self::QUEUE_OPTION, $queue, false);
279
280 if (empty($queue)) {
281 self::complete_batch();
282 }
283 }
284
285 /**
286 * Convert a single attachment with error handling.
287 * Catches fatal errors (e.g. memory_limit) per-image so the batch continues.
288 */
289 private static function convert_single(int $attachment_id, array $settings, array &$progress): void {
290 try {
291 $success = Metasync_Image_Converter::convert_attachment($attachment_id, $settings);
292 $progress['processed']++;
293 if (!$success) {
294 $progress['failed']++;
295 }
296 } catch (\Throwable $e) {
297 $progress['processed']++;
298 $progress['failed']++;
299 error_log('[MetaSync Media Opt] Batch conversion error for attachment ' . $attachment_id . ': ' . $e->getMessage());
300 }
301 }
302
303 /**
304 * Mark batch as completed and clean up.
305 */
306 private static function complete_batch(): void {
307 $progress = self::get_progress();
308 if ($progress['status'] === 'running') {
309 $progress['status'] = 'completed';
310 update_option(self::PROGRESS_OPTION, $progress, false);
311 }
312
313 delete_option(self::QUEUE_OPTION);
314 delete_option('metasync_batch_optimize_settings');
315 wp_clear_scheduled_hook(self::CRON_HOOK);
316 }
317
318 /**
319 * Query all JPEG/PNG attachment IDs that have not been converted.
320 *
321 * Uses $wpdb->get_col() with keyset (cursor) pagination instead of
322 * WP_Query/get_posts with posts_per_page=-1, avoiding memory spikes
323 * on sites with large media libraries (50k+ images).
324 *
325 * Keyset pagination (WHERE p.ID > last_id) is used instead of
326 * LIMIT/OFFSET to maintain constant query performance regardless
327 * of page depth and immunity to concurrent inserts/deletes.
328 *
329 * @return int[] Attachment IDs.
330 */
331 private static function query_unoptimized_ids(): array {
332 global $wpdb;
333
334 $ids = [];
335 $last_id = 0;
336
337 do {
338 $batch = $wpdb->get_col(
339 $wpdb->prepare(
340 "SELECT p.ID
341 FROM {$wpdb->posts} p
342 LEFT JOIN {$wpdb->postmeta} pm
343 ON pm.post_id = p.ID AND pm.meta_key = '_metasync_converted_format'
344 WHERE p.post_type = 'attachment'
345 AND p.post_status = 'inherit'
346 AND p.post_mime_type IN ('image/jpeg', 'image/png')
347 AND pm.meta_id IS NULL
348 AND p.ID > %d
349 ORDER BY p.ID ASC
350 LIMIT %d",
351 $last_id,
352 self::QUERY_PAGE_SIZE
353 )
354 );
355
356 if (!empty($batch)) {
357 $int_batch = array_map('intval', $batch);
358 array_push($ids, ...$int_batch);
359 $last_id = end($int_batch);
360 }
361 } while (!empty($batch) && count($batch) === self::QUERY_PAGE_SIZE);
362
363 return $ids;
364 }
365 }
366