get_results( $wpdb->prepare( "SELECT attachment_id, file_url FROM {$wpdb->prefix}mra_media_index WHERE external_status = 'queued' ORDER BY attachment_id ASC LIMIT %d", $batch_size ), ARRAY_A ); // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared if ( ! $rows ) { return 0; } foreach ( $rows as $row ) { $aid_val = $row['attachment_id'] ?? null; $url_val = $row['file_url'] ?? null; $aid = is_numeric( $aid_val ) ? (int) $aid_val : 0; $url = is_string( $url_val ) ? $url_val : ''; if ( $aid <= 0 || '' === $url ) { continue; } self::process_attachment( $aid, $url, $providers ); } return count( $rows ); } /** * Returns the count of attachments still waiting for external scanning. * * @return int */ public static function get_pending_count(): int { global $wpdb; $result = $wpdb->get_var( // phpcs:ignore WordPress.DB.DirectDatabaseQuery "SELECT COUNT(*) FROM {$wpdb->prefix}mra_media_index WHERE external_status = 'queued'" ); return is_numeric( $result ) ? (int) $result : 0; } /** * Processes one scheduled batch and re-queues if more remain. * * Called by Action Scheduler via the mra_external_scan_batch hook. * * @return void */ public static function process_scheduled_batch(): void { self::scan_batch(); if ( self::get_pending_count() > 0 ) { Scheduler::schedule_single( self::AS_HOOK ); } } /** * Promotes all attachments with external_status = 'pending' to 'queued'. * * Does not schedule an Action Scheduler action. Useful in CLI context where * processing runs synchronously in a loop rather than via background jobs. * * @return void */ public static function queue_all_pending(): void { global $wpdb; $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery "UPDATE {$wpdb->prefix}mra_media_index SET external_status = 'queued' WHERE external_status = 'pending'" ); } /** * Marks all pending attachments as queued and schedules a background scan batch. * * Moves every attachment with external_status = 'pending' to 'queued' so the * status change is visible immediately in the admin list. Only schedules the * Action Scheduler action if there are now queued items and no batch is already running. * * @return void */ public static function schedule(): void { global $wpdb; $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery "UPDATE {$wpdb->prefix}mra_media_index SET external_status = 'queued' WHERE external_status = 'pending'" ); if ( self::get_pending_count() > 0 && ! Scheduler::has_pending( self::AS_HOOK ) ) { Scheduler::schedule_single( self::AS_HOOK ); } } /** * Marks specific attachments as pending and schedules a scan batch. * * Idempotent: already-pending attachments are left unchanged; * previously-scanned or error attachments are reset to pending. * * @param array $attachment_ids Attachment IDs to queue. Must be non-empty. * * @return void */ public static function queue_attachments( array $attachment_ids ): void { if ( empty( $attachment_ids ) ) { return; } global $wpdb; $ids = array_map( 'intval', $attachment_ids ); $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}mra_media_index SET external_status = 'queued', external_scanned_at = NULL WHERE attachment_id IN ({$placeholders}) AND external_status != 'queued'", ...$ids ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare // Schedule directly — do not call schedule() which would also mark all 'pending' items. if ( ! Scheduler::has_pending( self::AS_HOOK ) ) { Scheduler::schedule_single( self::AS_HOOK ); } } /** * Deletes external scan results and resets status to pending for given IDs. * * @param array $attachment_ids Attachment IDs to purge. * * @return void */ public static function purge( array $attachment_ids ): void { if ( empty( $attachment_ids ) ) { return; } global $wpdb; $ids = array_map( 'intval', $attachment_ids ); $placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) ); // phpcs:disable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare $wpdb->query( $wpdb->prepare( "DELETE FROM {$wpdb->prefix}mra_external_results WHERE attachment_id IN ({$placeholders})", ...$ids ) ); $wpdb->query( $wpdb->prepare( "UPDATE {$wpdb->prefix}mra_media_index SET external_status = 'pending', external_scanned_at = NULL WHERE attachment_id IN ({$placeholders})", ...$ids ) ); // phpcs:enable WordPress.DB.DirectDatabaseQuery,WordPress.DB.PreparedSQL.InterpolatedNotPrepared,WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare } // ------------------------------------------------------------------------- // Private helpers // ------------------------------------------------------------------------- /** * Scans a single attachment with all providers and updates the index. * * @param int $attachment_id WordPress attachment ID. * @param string $file_url Public URL of the image. * @param array $providers Resolved provider list. * * @return void */ private static function process_attachment( int $attachment_id, string $file_url, array $providers ): void { global $wpdb; $any_success = false; $any_match = false; foreach ( $providers as $provider ) { try { $result = $provider->scan( $attachment_id, $file_url ); self::save_result( $attachment_id, $provider->provider_slug(), $result ); $any_success = true; if ( $result->match_count() > 0 ) { $any_match = true; } } catch ( \RuntimeException $e ) { if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log error_log( sprintf( '[MRA] %s scan failed for attachment %d: %s', $provider->provider_slug(), $attachment_id, $e->getMessage() ) ); } } } if ( ! $any_success ) { $new_status = 'error'; } elseif ( $any_match ) { $new_status = 'matches'; } else { $new_status = 'scanned'; } $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prefix . 'mra_media_index', array( 'external_status' => $new_status, 'external_scanned_at' => current_time( 'mysql', true ), ), array( 'attachment_id' => $attachment_id ), array( '%s', '%s' ), array( '%d' ) ); } /** * Persists a provider's scan result (upsert via REPLACE INTO). * * @param int $attachment_id WordPress attachment ID. * @param string $provider_slug Provider machine-readable slug. * @param ScanResult $result Scan result to store. * * @return void */ private static function save_result( int $attachment_id, string $provider_slug, ScanResult $result ): void { global $wpdb; $raw_json = wp_json_encode( $result->raw_response() ); $domains_json = wp_json_encode( $result->top_domains() ); $wpdb->replace( // phpcs:ignore WordPress.DB.DirectDatabaseQuery $wpdb->prefix . 'mra_external_results', array( 'attachment_id' => $attachment_id, 'provider' => $provider_slug, 'raw_response' => ( false !== $raw_json ) ? $raw_json : '{}', 'match_count' => $result->match_count(), 'top_domains' => ( false !== $domains_json ) ? $domains_json : '{}', 'created_at' => current_time( 'mysql', true ), ), array( '%d', '%s', '%s', '%d', '%s', '%s' ) ); } /** * Resolves active providers from the mra/external/providers filter. * * @return array */ private static function get_active_providers(): array { $raw = apply_filters( 'mra/external/providers', array() ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores $providers = array(); if ( is_array( $raw ) ) { foreach ( $raw as $p ) { if ( $p instanceof AbstractProvider ) { $providers[] = $p; } } } return $providers; } }