content = $content; add_action( 'init', array( $this, 'maybe_upgrade_legacy_manifests' ), 25 ); add_action( 'init', array( $this, 'maybe_schedule_initial_generation' ), 30 ); add_action( 'init', array( $this, 'maybe_recover_stalled_jobs' ), 35 ); add_action( 'save_post', array( $this, 'post_saved' ), 100, 3 ); add_action( 'elementor/editor/after_save', array( $this, 'elementor_saved' ), 100, 2 ); add_action( 'added_post_meta', array( $this, 'elementor_meta_saved' ), 100, 4 ); add_action( 'updated_post_meta', array( $this, 'elementor_meta_saved' ), 100, 4 ); add_action( 'update_option_ai_narration_settings', array( $this, 'settings_updated' ), 10, 2 ); add_action( self::DISCOVERY_HOOK, array( $this, 'schedule_discovered_post' ), 10, 2 ); add_action( self::CRON_HOOK, array( $this, 'generate_next_chunk' ), 10, 3 ); add_action( self::WATCHDOG_HOOK, array( $this, 'generate_next_chunk' ), 10, 3 ); add_action( 'admin_post_ai_narration_regenerate_article', array( $this, 'regenerate_article_action' ) ); } public function maybe_schedule_initial_generation(): void { if ( '1' === (string) get_option( self::BOOTSTRAP_OPTION, '' ) ) { return; } // Enabling automatic generation must not create an unexpected provider // bill by bulk-generating every established eligible item on activation. // Future publication, content updates and explicit eligibility changes // use the normal discovery path below. update_option( self::BOOTSTRAP_OPTION, '1', false ); } public function maybe_upgrade_legacy_manifests(): void { if ( '1' === (string) get_option( self::MANIFEST_MIGRATION_OPTION, '' ) ) { return; } $post_ids = get_posts( array( 'post_type' => 'any', 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', 'meta_key' => self::MANIFEST_META, 'no_found_rows' => true, ) ); foreach ( $post_ids as $post_id ) { $post_id = (int) $post_id; $manifest = get_post_meta( $post_id, self::MANIFEST_META, true ); if ( ! is_array( $manifest ) || ( ! empty( $manifest['content_hash'] ) && ! empty( $manifest['profile_signature'] ) ) ) { continue; } try { $this->maybe_upgrade_manifest( $post_id, $manifest, $this->content->prepare( $post_id ) ); } catch ( Throwable $exception ) { // A cache that cannot be proven current remains unavailable until rebuilt. } } update_option( self::MANIFEST_MIGRATION_OPTION, '1', false ); } public function post_saved( int $post_id, WP_Post $post, bool $updated ): void { unset( $updated ); if ( wp_is_post_autosave( $post_id ) || wp_is_post_revision( $post_id ) || 'publish' !== $post->post_status ) { return; } $this->queue_discovery( $post_id, 5 ); } /** * Elementor saves its document data after parts of WordPress's normal save * lifecycle. Schedule once more so the job sees the completed HTML widget. */ public function elementor_saved( int $post_id, array $editor_data = array() ): void { unset( $editor_data ); $this->queue_discovery( $post_id, 2 ); } /** * Elementor's saved document is ultimately persisted in post meta. Watching * that write provides a reliable backstop for editor versions or hosting * paths where the editor-specific action does not reach this request. * * @param mixed $meta_value Saved metadata value. */ public function elementor_meta_saved( int $meta_id, int $post_id, string $meta_key, $meta_value ): void { unset( $meta_id, $meta_value ); if ( '_elementor_data' !== $meta_key || wp_is_post_revision( $post_id ) || 'publish' !== get_post_status( $post_id ) ) { return; } $this->queue_discovery( $post_id, 2 ); } /** * Reconcile eligibility changes without treating experimentation with a * narration profile as approval to replace every cached article. * * @param mixed $old_value Previous option value. * @param mixed $new_value New option value. */ public function settings_updated( $old_value, $new_value ): void { $old = wp_parse_args( is_array( $old_value ) ? $old_value : array(), AI_Narration_Admin::default_settings() ); $new = wp_parse_args( is_array( $new_value ) ? $new_value : array(), AI_Narration_Admin::default_settings() ); $old_eligibility = wp_json_encode( array( $old['post_types'] ?? array(), $old['categories'] ?? array() ) ); $new_eligibility = wp_json_encode( array( $new['post_types'] ?? array(), $new['categories'] ?? array() ) ); $eligibility_changed = $old_eligibility !== $new_eligibility; if ( $this->settings_have_narration_credentials( $old ) && ! $this->settings_have_narration_credentials( $new ) ) { $this->cancel_jobs_after_credentials_removed(); } if ( $eligibility_changed ) { $this->schedule_all( 10 ); } } public function schedule( int $post_id, int $delay = 5, bool $force = false ): void { if ( ! AI_Narration_Admin::has_narration_credentials() || ! $this->content->is_eligible( $post_id ) ) { return; } if ( ! $this->content->has_narration_marker( $post_id ) ) { $this->remove_article_cache( $post_id ); return; } if ( $force ) { $this->cancel_article_jobs( $post_id ); } $settings = AI_Narration_Admin::get_settings(); $profile_id = $this->content->narration_profile( $post_id, $settings ); try { $prepared = $this->content->prepare( $post_id ); } catch ( Throwable $exception ) { $this->set_state( $post_id, 'failed', '', '', 0, 0, $exception->getMessage() ); return; } if ( ! $force && ! AI_Narration_Admin::automatic_cache_generation() ) { $this->reconcile_cache_state( $post_id, $prepared ); return; } $fingerprint = $this->fingerprint( $prepared, $profile_id ); if ( ! $force && null !== $this->get_manifest( $post_id, $prepared ) ) { $state = $this->state( $post_id ); $status = sanitize_key( (string) ( $state['status'] ?? '' ) ); $state_fingerprint = sanitize_text_field( (string) ( $state['fingerprint'] ?? '' ) ); $state_storage_key = sanitize_file_name( (string) ( $state['storage_key'] ?? '' ) ); $forced_job_is_current = in_array( $status, array( 'queued', 'generating', 'regenerating', 'retrying' ), true ) && '' !== $state_fingerprint && '' !== $state_storage_key && hash_equals( $fingerprint, $state_fingerprint ) && ! hash_equals( $state_fingerprint, $state_storage_key ); // A manual regeneration deliberately replaces valid audio with another // rendering of the same content, so an ordinary save must not cancel it. // Otherwise the article may have been edited back to the version already // represented by the active manifest. Cancel that superseded automatic // job instead of leaving its old progress record visible indefinitely. if ( ! $forced_job_is_current ) { $this->reconcile_cache_state( $post_id, $prepared ); } return; } $storage_key = $force ? $fingerprint . '-r' . strtolower( wp_generate_password( 10, false, false ) ) : $fingerprint; // A replacement is only regenerating once its worker has actually begun. $this->set_state( $post_id, 'queued', $fingerprint, $storage_key, 0, count( $prepared['chunks'] ), '', 0, (string) $prepared['source_hash'], $this->settings_signature( $settings, $profile_id ) ); $args = array( $post_id, $fingerprint, $storage_key ); if ( ! wp_next_scheduled( self::CRON_HOOK, $args ) ) { wp_schedule_single_event( time() + max( 1, $delay ), self::CRON_HOOK, $args ); } if ( ! wp_next_scheduled( self::WATCHDOG_HOOK, $args ) ) { wp_schedule_single_event( time() + $this->lock_seconds() + 30, self::WATCHDOG_HOOK, $args ); } AI_Narration_Background::kick( max( 1, $delay ) ); } /** * Recover queue records whose worker event was lost or interrupted. * * The scan is deliberately cheap and throttled. It never calls OpenAI; it * only restores a missing near-term worker event for an abandoned job. */ public function maybe_recover_stalled_jobs(): void { if ( false !== get_transient( self::RECOVERY_TRANSIENT ) ) { return; } set_transient( self::RECOVERY_TRANSIENT, 1, MINUTE_IN_SECONDS ); $post_types = get_post_types( array( 'public' => true ), 'names' ); unset( $post_types['attachment'] ); $post_ids = get_posts( array( 'post_type' => array_values( $post_types ), 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', 'meta_key' => self::STATE_META, 'no_found_rows' => true, 'update_post_meta_cache' => true, 'update_post_term_cache' => false, ) ); $now = time(); foreach ( $post_ids as $post_id ) { $post_id = (int) $post_id; $state = $this->state( $post_id ); $status = sanitize_key( (string) ( $state['status'] ?? '' ) ); $updated = absint( $state['updated'] ?? 0 ); $next_attempt = absint( $state['next_attempt'] ?? 0 ); if ( ! in_array( $status, array( 'queued', 'generating', 'regenerating', 'retrying' ), true ) || 0 === $updated || $updated > $now - self::RECOVERY_GRACE_SECONDS ) { continue; } $fingerprint = sanitize_text_field( (string) ( $state['fingerprint'] ?? '' ) ); $storage_key = sanitize_file_name( (string) ( $state['storage_key'] ?? '' ) ); if ( '' === $fingerprint || '' === $storage_key || false !== get_transient( 'ai_narration_cache_lock_' . $post_id ) || AI_Narration_Background::worker_lock_active( 'article', $post_id ) ) { continue; } $args = array( $post_id, $fingerprint, $storage_key ); $next_worker = wp_next_scheduled( self::CRON_HOOK, $args ); $next_watchdog = wp_next_scheduled( self::WATCHDOG_HOOK, $args ); if ( 'retrying' === $status && $next_attempt > $now ) { if ( false === $next_worker ) { wp_schedule_single_event( $next_attempt, self::CRON_HOOK, $args ); } continue; } if ( 'retrying' === $status && false !== $next_worker && (int) $next_worker >= $now - 30 ) { continue; } if ( false !== $next_worker && (int) $next_worker < $now - 30 ) { wp_unschedule_event( (int) $next_worker, self::CRON_HOOK, $args ); $next_worker = false; } if ( false !== $next_watchdog && (int) $next_watchdog < $now - 30 ) { wp_unschedule_event( (int) $next_watchdog, self::WATCHDOG_HOOK, $args ); $next_watchdog = false; } $worker_is_imminent = false !== $next_worker && (int) $next_worker <= $now + 60; $watchdog_is_imminent = false !== $next_watchdog && (int) $next_watchdog <= $now + 60; if ( $worker_is_imminent || $watchdog_is_imminent ) { continue; } if ( false !== $next_worker ) { wp_unschedule_event( (int) $next_worker, self::CRON_HOOK, $args ); } wp_schedule_single_event( $now + 2, self::CRON_HOOK, $args ); AI_Narration_Background::kick( 2 ); } } /** Restore only due or missing durable Article jobs, without bypassing backoff. */ public function run_due_queue_now(): int { $post_ids = get_posts( array( 'post_type' => 'any', 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', 'meta_key' => self::STATE_META, 'no_found_rows' => true, ) ); $now = time(); $restored = 0; foreach ( $post_ids as $post_id ) { $post_id = absint( $post_id ); $state = $this->state( $post_id ); $status = sanitize_key( (string) ( $state['status'] ?? '' ) ); if ( ! in_array( $status, array( 'queued', 'generating', 'regenerating', 'retrying' ), true ) || absint( $state['next_attempt'] ?? 0 ) > $now ) { continue; } $fingerprint = sanitize_text_field( (string) ( $state['fingerprint'] ?? '' ) ); $storage_key = sanitize_file_name( (string) ( $state['storage_key'] ?? '' ) ); if ( '' === $fingerprint || '' === $storage_key || AI_Narration_Background::worker_lock_active( 'article', $post_id ) ) { continue; } $args = array( $post_id, $fingerprint, $storage_key ); $next_worker = wp_next_scheduled( self::CRON_HOOK, $args ); if ( false !== $next_worker && (int) $next_worker > $now + MINUTE_IN_SECONDS ) { wp_unschedule_event( (int) $next_worker, self::CRON_HOOK, $args ); $next_worker = false; } if ( false === $next_worker ) { wp_schedule_single_event( $now + 1, self::CRON_HOOK, $args ); } $restored++; } if ( $restored > 0 ) { AI_Narration_Background::kick(); } return $restored; } /** Requeue each terminal Article failure once; normal retry limits then apply. */ public function retry_failed_jobs(): int { $queued = 0; $delay = 1; foreach ( $this->articles() as $article ) { if ( 'failed' !== sanitize_key( (string) ( $article['status'] ?? '' ) ) ) { continue; } $this->schedule( absint( $article['post_id'] ?? 0 ), $delay, true ); $queued++; $delay += 5; } return $queued; } public function schedule_all( int $initial_delay = 5, bool $force = false ): void { $post_types = get_post_types( array( 'public' => true ), 'names' ); unset( $post_types['attachment'] ); $post_ids = get_posts( array( 'post_type' => array_values( $post_types ), 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', 'orderby' => 'ID', 'order' => 'ASC', 'no_found_rows' => true, 'update_post_meta_cache' => false, 'update_post_term_cache' => false, ) ); $offset = 0; foreach ( $post_ids as $post_id ) { $post_id = (int) $post_id; if ( $this->content->is_eligible( $post_id ) && $this->content->has_narration_marker( $post_id ) ) { $this->queue_discovery( (int) $post_id, $initial_delay + $offset, $force ); $offset += 5; } elseif ( get_post_meta( $post_id, self::MANIFEST_META, true ) || get_post_meta( $post_id, self::STATE_META, true ) ) { $this->remove_article_cache( $post_id ); } } } public function schedule_discovered_post( int $post_id, int $force ): void { $this->schedule( $post_id, 2, 1 === $force ); } private function queue_discovery( int $post_id, int $delay, bool $force = false ): void { $args = array( $post_id, $force ? 1 : 0 ); $target_timestamp = time() + max( 1, $delay ); $next_timestamp = wp_next_scheduled( self::DISCOVERY_HOOK, $args ); if ( false !== $next_timestamp ) { if ( (int) $next_timestamp <= $target_timestamp ) { return; } wp_unschedule_event( (int) $next_timestamp, self::DISCOVERY_HOOK, $args ); } wp_schedule_single_event( $target_timestamp, self::DISCOVERY_HOOK, $args ); if ( $delay <= 10 ) { AI_Narration_Background::kick( max( 1, $delay ) ); } } /** * @param array|null $prepared Already prepared content when available. * A narration made with a previous voice or instruction profile remains * safe to serve as long as the published article content still matches. * * @return array{fingerprint:string,storage_key:string,chunks:array,created:int,profile_current:bool}|null */ public function get_manifest( int $post_id, ?array $prepared = null ): ?array { $manifest = get_post_meta( $post_id, self::MANIFEST_META, true ); if ( ! is_array( $manifest ) || empty( $manifest['fingerprint'] ) || empty( $manifest['chunks'] ) || ! is_array( $manifest['chunks'] ) ) { return null; } if ( null === $prepared ) { try { $prepared = $this->content->prepare( $post_id ); } catch ( Throwable $exception ) { return null; } } $manifest = $this->maybe_upgrade_manifest( $post_id, $manifest, $prepared ); $content_hash = sanitize_text_field( (string) ( $manifest['content_hash'] ?? '' ) ); if ( '' === $content_hash || ! hash_equals( (string) $prepared['source_hash'], $content_hash ) ) { return null; } return $this->manifest_files( $post_id, $manifest ); } /** * Return completed stored audio without requiring it to match the latest * article text. This is used only while replacement audio is pending or has * failed, so the existing public and administrator Play controls remain live * until a complete replacement manifest is published atomically. * * @return array{fingerprint:string,storage_key:string,chunks:array,created:int,profile_current:bool}|null */ public function get_available_manifest( int $post_id ): ?array { $manifest = get_post_meta( $post_id, self::MANIFEST_META, true ); if ( ! is_array( $manifest ) || empty( $manifest['fingerprint'] ) || empty( $manifest['chunks'] ) || ! is_array( $manifest['chunks'] ) ) { return null; } return $this->manifest_files( $post_id, $manifest ); } /** * Prefer narration matching the current article, then retain the last * completed narration only across an active or failed replacement cycle. * * @param array|null $prepared Already prepared current content. * @return array{fingerprint:string,storage_key:string,chunks:array,created:int,profile_current:bool}|null */ public function get_playable_manifest( int $post_id, ?array $prepared = null ): ?array { $current = $this->get_manifest( $post_id, $prepared ); if ( null !== $current ) { return $current; } // Free never generates from a public request. Keep the last completed // narration playable after an edit until an administrator regenerates it. return $this->get_available_manifest( $post_id ); } /** * @param array $manifest Stored manifest data. * @return array{fingerprint:string,storage_key:string,chunks:array,created:int,profile_current:bool}|null */ private function manifest_files( int $post_id, array $manifest ): ?array { if ( empty( $manifest['fingerprint'] ) || empty( $manifest['chunks'] ) || ! is_array( $manifest['chunks'] ) ) { return null; } $fingerprint = sanitize_text_field( (string) $manifest['fingerprint'] ); $storage_key = ! empty( $manifest['storage_key'] ) ? sanitize_file_name( (string) $manifest['storage_key'] ) : $fingerprint; $location = $this->location( $post_id, $storage_key ); $urls = array(); foreach ( $manifest['chunks'] as $filename ) { $filename = sanitize_file_name( (string) $filename ); if ( '' === $filename || ! is_file( trailingslashit( $location['dir'] ) . $filename ) ) { return null; } $urls[] = trailingslashit( $location['url'] ) . rawurlencode( $filename ); } $settings = AI_Narration_Admin::get_settings(); $profile_id = $this->content->narration_profile( $post_id, $settings ); return array( 'fingerprint' => $fingerprint, 'storage_key' => $storage_key, 'chunks' => $urls, 'created' => isset( $manifest['created'] ) ? absint( $manifest['created'] ) : 0, 'profile_current' => ! empty( $manifest['profile_signature'] ) && hash_equals( $this->settings_signature( $settings, $profile_id ), (string) $manifest['profile_signature'] ), ); } private function replacement_is_pending( int $post_id ): bool { $status = sanitize_key( (string) ( $this->state( $post_id )['status'] ?? '' ) ); if ( in_array( $status, array( 'queued', 'generating', 'regenerating', 'retrying', 'failed' ), true ) ) { return true; } return false !== wp_next_scheduled( self::DISCOVERY_HOOK, array( $post_id, 0 ) ) || false !== wp_next_scheduled( self::DISCOVERY_HOOK, array( $post_id, 1 ) ); } public function generate_next_chunk( int $post_id, string $expected_fingerprint, string $storage_key = '' ): void { // Beta 1 background events carried only the post ID and fingerprint. // Treat their content fingerprint as the storage key so an in-flight // upgrade cannot turn an already queued job into a PHP argument error. if ( '' === $storage_key ) { $storage_key = sanitize_file_name( $expected_fingerprint ); } if ( ! $this->is_active_job( $post_id, $expected_fingerprint, $storage_key ) ) { return; } $legacy_lock_key = 'ai_narration_cache_lock_' . $post_id; if ( false !== get_transient( $legacy_lock_key ) ) { $this->reschedule_after_lock_contention( $post_id, $expected_fingerprint, $storage_key ); return; } $lock_seconds = $this->lock_seconds(); $lock_token = AI_Narration_Background::acquire_worker_lock( 'article', $post_id, $lock_seconds ); if ( '' === $lock_token ) { $this->reschedule_after_lock_contention( $post_id, $expected_fingerprint, $storage_key ); return; } $provider_lock_token = ''; try { if ( ! $this->is_active_job( $post_id, $expected_fingerprint, $storage_key ) ) { AI_Narration_Background::release_worker_lock( 'article', $post_id, $lock_token ); return; } if ( ! AI_Narration_Admin::has_narration_credentials() ) { AI_Narration_Background::release_worker_lock( 'article', $post_id, $lock_token ); $this->cancel_generation_preserving_audio( $post_id ); return; } if ( ! $this->content->is_eligible( $post_id ) ) { AI_Narration_Background::release_worker_lock( 'article', $post_id, $lock_token ); return; } if ( ! $this->content->has_narration_marker( $post_id ) ) { $this->remove_article_cache( $post_id ); AI_Narration_Background::release_worker_lock( 'article', $post_id, $lock_token ); return; } $settings = AI_Narration_Admin::get_settings(); $profile_id = $this->content->narration_profile( $post_id, $settings ); $prepared = $this->content->prepare( $post_id ); $fingerprint = $this->fingerprint( $prepared, $profile_id ); if ( ! hash_equals( $expected_fingerprint, $fingerprint ) ) { AI_Narration_Background::release_worker_lock( 'article', $post_id, $lock_token ); $this->schedule( $post_id, 5 ); return; } if ( ! $this->is_current_job( $post_id, $fingerprint, $storage_key ) ) { AI_Narration_Background::release_worker_lock( 'article', $post_id, $lock_token ); return; } $location = $this->location( $post_id, $storage_key ); if ( ! wp_mkdir_p( $location['dir'] ) ) { throw new RuntimeException( 'The narration cache directory could not be created.' ); } $this->ensure_cache_delivery_files(); $total = count( $prepared['chunks'] ); $profile = $this->profile( $settings, $profile_id ); $next = null; for ( $index = 0; $index < $total; $index++ ) { $path = trailingslashit( $location['dir'] ) . $this->chunk_filename( $index, $profile['extension'] ); if ( ! is_file( $path ) || filesize( $path ) < 1024 ) { $next = $index; break; } } if ( null === $next ) { if ( ! $this->is_current_job( $post_id, $fingerprint, $storage_key ) ) { AI_Narration_Background::release_worker_lock( 'article', $post_id, $lock_token ); return; } $this->publish_manifest( $post_id, $fingerprint, $storage_key, $total, $profile_id, (string) $prepared['source_hash'], $this->settings_signature( $settings, $profile_id ) ); AI_Narration_Background::release_worker_lock( 'article', $post_id, $lock_token ); AI_Narration_Background::kick(); return; } $current_state = $this->state( $post_id ); $current_attempts = isset( $current_state['attempts'] ) ? absint( $current_state['attempts'] ) : 0; $has_active = null !== $this->get_available_manifest( $post_id ); if ( ! $this->is_current_job( $post_id, $fingerprint, $storage_key ) ) { AI_Narration_Background::release_worker_lock( 'article', $post_id, $lock_token ); return; } $this->set_state( $post_id, $has_active ? 'regenerating' : 'generating', $fingerprint, $storage_key, $next, $total, '', $current_attempts, (string) $prepared['source_hash'], $this->settings_signature( $settings, $profile_id ) ); // A watchdog event survives a hosting-level timeout or fatal shutdown. // Successful jobs may leave one harmless no-op event that will find the // completed manifest and return without calling OpenAI. if ( ! wp_next_scheduled( self::WATCHDOG_HOOK, array( $post_id, $fingerprint, $storage_key ) ) ) { wp_schedule_single_event( time() + $lock_seconds + 30, self::WATCHDOG_HOOK, array( $post_id, $fingerprint, $storage_key ) ); } if ( self::$generated_in_request ) { AI_Narration_Background::release_worker_lock( 'article', $post_id, $lock_token ); wp_schedule_single_event( time() + 1, self::CRON_HOOK, array( $post_id, $fingerprint, $storage_key ) ); AI_Narration_Background::kick(); return; } $provider_lock_token = AI_Narration_Background::acquire_provider_lock( $lock_seconds ); if ( '' === $provider_lock_token ) { AI_Narration_Background::release_worker_lock( 'article', $post_id, $lock_token ); $this->reschedule_after_lock_contention( $post_id, $fingerprint, $storage_key ); return; } self::$generated_in_request = true; $generated = $this->request_audio( (string) $prepared['chunks'][ $next ], $profile_id ); AI_Narration_Background::release_worker_lock( 'provider', 0, $provider_lock_token ); $provider_lock_token = ''; if ( ! $this->is_current_job( $post_id, $fingerprint, $storage_key ) ) { AI_Narration_Background::release_worker_lock( 'article', $post_id, $lock_token ); return; } if ( $generated['extension'] !== $profile['extension'] || $generated['mime'] !== $profile['mime'] ) { throw new RuntimeException( 'The generated narration format did not match the selected provider profile.' ); } $this->write_chunk( trailingslashit( $location['dir'] ) . $this->chunk_filename( $next, $profile['extension'] ), $generated['audio'] ); AI_Narration_Background::record_worker_activity(); if ( ! $this->is_current_job( $post_id, $fingerprint, $storage_key ) ) { $this->delete_tree( $location['dir'] ); AI_Narration_Background::release_worker_lock( 'article', $post_id, $lock_token ); return; } $this->set_state( $post_id, $has_active ? 'regenerating' : 'generating', $fingerprint, $storage_key, $next + 1, $total, '', 0, (string) $prepared['source_hash'], $this->settings_signature( $settings, $profile_id ) ); AI_Narration_Background::release_worker_lock( 'article', $post_id, $lock_token ); wp_schedule_single_event( time() + 1, self::CRON_HOOK, array( $post_id, $fingerprint, $storage_key ) ); AI_Narration_Background::kick(); } catch ( Throwable $exception ) { AI_Narration_Background::release_worker_lock( 'provider', 0, $provider_lock_token ); AI_Narration_Background::release_worker_lock( 'article', $post_id, $lock_token ); if ( ! $this->is_active_job( $post_id, $expected_fingerprint, $storage_key ) ) { return; } $state = $this->state( $post_id ); $attempts = isset( $state['attempts'] ) ? absint( $state['attempts'] ) + 1 : 1; $provider_failure = $exception instanceof AI_Narration_Provider_Exception ? $exception : null; $terminal = null !== $provider_failure && ! $provider_failure->is_retryable(); $failed = $terminal || $attempts >= self::MAX_ATTEMPTS; $stored_attempts = $terminal ? self::MAX_ATTEMPTS : $attempts; $args = array( $post_id, $expected_fingerprint, $storage_key ); wp_clear_scheduled_hook( self::CRON_HOOK, $args ); wp_clear_scheduled_hook( self::WATCHDOG_HOOK, $args ); $retry_delay = $failed ? 0 : ( null !== $provider_failure ? $provider_failure->retry_delay( $attempts ) : 30 * $attempts ); $this->set_state( $post_id, $failed ? 'failed' : 'retrying', $expected_fingerprint, $storage_key, (int) ( $state['completed'] ?? 0 ), (int) ( $state['total'] ?? 0 ), $exception->getMessage(), $stored_attempts, (string) ( $state['content_hash'] ?? '' ), (string) ( $state['profile_signature'] ?? '' ), $failed ? 0 : time() + $retry_delay ); if ( $failed ) { return; } else { wp_schedule_single_event( time() + $retry_delay, self::CRON_HOOK, $args ); wp_schedule_single_event( time() + $retry_delay + $lock_seconds + 30, self::WATCHDOG_HOOK, $args ); } } } /** * Retry a valid job after another worker releases its lock and explicitly * wake WordPress cron. Without the wake-up, a rescheduled job can wait for * ordinary traffic or the minute-level recovery fallback. */ private function reschedule_after_lock_contention( int $post_id, string $fingerprint, string $storage_key ): void { $args = array( $post_id, $fingerprint, $storage_key ); if ( ! wp_next_scheduled( self::CRON_HOOK, $args ) ) { wp_schedule_single_event( time() + self::LOCK_RETRY_DELAY, self::CRON_HOOK, $args ); } AI_Narration_Background::kick( self::LOCK_RETRY_DELAY ); } private function is_active_job( int $post_id, string $fingerprint, string $storage_key ): bool { $state = $this->state( $post_id ); $status = sanitize_key( (string) ( $state['status'] ?? '' ) ); $attempts = isset( $state['attempts'] ) ? absint( $state['attempts'] ) : 0; return $attempts < self::MAX_ATTEMPTS && in_array( $status, array( 'queued', 'generating', 'regenerating', 'retrying' ), true ) && $this->is_current_job( $post_id, $fingerprint, $storage_key ); } private function lock_seconds(): int { return self::DEFAULT_LOCK_SECONDS; } private function is_current_job( int $post_id, string $fingerprint, string $storage_key ): bool { $state = $this->state( $post_id ); $current_fingerprint = sanitize_text_field( (string) ( $state['fingerprint'] ?? '' ) ); $current_storage_key = sanitize_file_name( (string) ( $state['storage_key'] ?? $current_fingerprint ) ); return '' !== $current_fingerprint && '' !== $current_storage_key && hash_equals( $fingerprint, $current_fingerprint ) && hash_equals( $storage_key, $current_storage_key ); } /** @return array */ public function state( int $post_id ): array { $state = get_post_meta( $post_id, self::STATE_META, true ); return is_array( $state ) ? $state : array( 'status' => 'missing' ); } /** * @param array>|null $articles Preloaded article rows when available. * @return array{ready:int,previous:int,queued:int,failed:int,missing:int,bytes:int} */ public function summary( ?array $articles = null ): array { $summary = array( 'ready' => 0, 'previous' => 0, 'queued' => 0, 'failed' => 0, 'missing' => 0, 'bytes' => 0 ); foreach ( null === $articles ? $this->articles() : $articles as $article ) { $summary['bytes'] += (int) $article['bytes']; $status = (string) $article['status']; if ( 'ready' === $status ) { $summary['ready']++; } elseif ( 'previous' === $status ) { $summary['previous']++; } elseif ( in_array( $status, array( 'queued', 'generating', 'regenerating', 'retrying' ), true ) ) { $summary['queued']++; } elseif ( 'failed' === $status ) { $summary['failed']++; } else { $summary['missing']++; } } return $summary; } /** * @param array|null $requested_post_ids Limit the result to known post IDs when supplied. * @return array> */ public function articles( ?array $requested_post_ids = null ): array { $articles = array(); $settings = AI_Narration_Admin::get_settings(); if ( null === $requested_post_ids ) { $post_types = get_post_types( array( 'public' => true ), 'names' ); unset( $post_types['attachment'] ); $post_ids = get_posts( array( 'post_type' => array_values( $post_types ), 'post_status' => 'publish', 'posts_per_page' => -1, 'fields' => 'ids', 'orderby' => 'title', 'order' => 'ASC', 'no_found_rows' => true ) ); } else { $post_ids = array(); foreach ( $requested_post_ids as $requested_post_id ) { if ( is_scalar( $requested_post_id ) ) { $post_ids[] = absint( $requested_post_id ); } } $post_ids = array_values( array_unique( array_filter( $post_ids ) ) ); } foreach ( $post_ids as $post_id ) { $post_id = (int) $post_id; if ( ! $this->content->is_eligible( $post_id ) ) { continue; } // Targeted status checks only receive IDs already present in the table. // The periodic full reconciliation performs the potentially expensive // Elementor marker check and removes rows that are no longer eligible. if ( null === $requested_post_ids && ! $this->content->has_narration_marker( $post_id ) ) { continue; } $profile_id = $this->content->narration_profile( $post_id, $settings ); $current_profile_signature = $this->settings_signature( $settings, $profile_id ); $manifest = get_post_meta( $post_id, self::MANIFEST_META, true ); $state = $this->state( $post_id ); $bytes = 0; $chunks = 0; $created = 0; $model = ''; $voice = ''; $provider = ''; $urls = array(); $has_active = false; $active_current = false; if ( is_array( $manifest ) && ! empty( $manifest['fingerprint'] ) && ! empty( $manifest['chunks'] ) ) { if ( empty( $manifest['content_hash'] ) || empty( $manifest['profile_signature'] ) ) { try { $manifest = $this->maybe_upgrade_manifest( $post_id, $manifest, $this->content->prepare( $post_id ) ); } catch ( Throwable $exception ) { // Leave an unverifiable legacy manifest unavailable until the article is regenerated. } } $storage_key = ! empty( $manifest['storage_key'] ) ? (string) $manifest['storage_key'] : (string) $manifest['fingerprint']; $location = $this->location( $post_id, $storage_key ); foreach ( (array) $manifest['chunks'] as $filename ) { $path = trailingslashit( $location['dir'] ) . sanitize_file_name( (string) $filename ); if ( is_file( $path ) ) { $bytes += (int) filesize( $path ); $chunks++; $urls[] = trailingslashit( $location['url'] ) . rawurlencode( sanitize_file_name( (string) $filename ) ); } } $has_active = $chunks === count( (array) $manifest['chunks'] ); $created = isset( $manifest['created'] ) ? absint( $manifest['created'] ) : 0; $model = sanitize_text_field( (string) ( $manifest['model'] ?? '' ) ); $voice = sanitize_text_field( (string) ( $manifest['voice'] ?? '' ) ); $provider = sanitize_key( (string) ( $manifest['provider'] ?? 'openai' ) ); } $manifest_content_hash = is_array( $manifest ) ? sanitize_text_field( (string) ( $manifest['content_hash'] ?? '' ) ) : ''; $state_content_hash = sanitize_text_field( (string) ( $state['content_hash'] ?? '' ) ); $active_current = $has_active && '' !== $manifest_content_hash && '' !== $state_content_hash && hash_equals( $manifest_content_hash, $state_content_hash ); if ( ! $active_current && $has_active && empty( $state['content_hash'] ) && ! empty( $state['fingerprint'] ) ) { $active_current = hash_equals( (string) $manifest['fingerprint'], (string) $state['fingerprint'] ); } $profile_current = $has_active && ! empty( $manifest['profile_signature'] ) && hash_equals( $current_profile_signature, (string) $manifest['profile_signature'] ); $status = (string) ( $state['status'] ?? 'missing' ); if ( $has_active && ! in_array( $status, array( 'queued', 'generating', 'regenerating', 'retrying', 'failed' ), true ) ) { $status = $active_current && $profile_current ? 'ready' : 'previous'; } $discovery_pending = false !== wp_next_scheduled( self::DISCOVERY_HOOK, array( $post_id, 0 ) ) || false !== wp_next_scheduled( self::DISCOVERY_HOOK, array( $post_id, 1 ) ); if ( AI_Narration_Admin::automatic_cache_generation() && $discovery_pending && ! in_array( $status, array( 'generating', 'regenerating', 'retrying' ), true ) ) { $status = 'queued'; } $audio_available = $has_active; $articles[] = array( 'post_id' => $post_id, 'title' => get_the_title( $post_id ), 'post_type' => get_post_type( $post_id ) ?: 'post', 'profile' => $profile_id, 'status' => $status, 'has_active' => $has_active, 'active_current' => $active_current, 'audio_available' => $audio_available, 'profile_current' => $profile_current, 'completed' => absint( $state['completed'] ?? 0 ), 'total' => absint( $state['total'] ?? $chunks ), 'bytes' => $bytes, 'chunks' => $chunks, 'created' => $created, 'model' => $model, 'voice' => $voice, 'provider' => $provider, 'urls' => $audio_available ? $urls : array(), 'error' => sanitize_text_field( (string) ( $state['error'] ?? '' ) ), 'updated' => absint( $state['updated'] ?? 0 ), 'next_attempt' => absint( $state['next_attempt'] ?? 0 ), ); } return $articles; } public function regenerate_article_action(): void { $post_id = isset( $_GET['post_id'] ) ? absint( wp_unslash( $_GET['post_id'] ) ) : 0; if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You are not allowed to manage narration audio.', 'ai-narration' ) ); } check_admin_referer( 'ai_narration_regenerate_article_' . $post_id ); if ( ! AI_Narration_Admin::has_narration_credentials() ) { $state = $this->state( $post_id ); $this->set_state( $post_id, 'failed', sanitize_text_field( (string) ( $state['fingerprint'] ?? '' ) ), sanitize_file_name( (string) ( $state['storage_key'] ?? '' ) ), absint( $state['completed'] ?? 0 ), absint( $state['total'] ?? 0 ), __( 'Add an OpenAI API key before generating narration.', 'ai-narration' ), absint( $state['attempts'] ?? 0 ), sanitize_text_field( (string) ( $state['content_hash'] ?? '' ) ), sanitize_text_field( (string) ( $state['profile_signature'] ?? '' ) ), 0 ); wp_safe_redirect( AI_Narration_Admin::settings_url( array( 'tab' => 'cache' ) ) ); exit; } $this->schedule( $post_id, 1, true ); wp_safe_redirect( AI_Narration_Admin::settings_url( array( 'tab' => 'cache', 'cache_action' => 'article_queued' ) ) ); exit; } /** * Cancel superseded work and make the stored state agree with whichever * narration, if any, is valid for the article's current content. * * @param array $prepared Prepared current article content. */ private function reconcile_cache_state( int $post_id, array $prepared ): void { $settings = AI_Narration_Admin::get_settings(); $profile_id = $this->content->narration_profile( $post_id, $settings ); $manifest = $this->get_manifest( $post_id, $prepared ); $this->cancel_article_jobs( $post_id ); if ( null !== $manifest ) { $raw_manifest = get_post_meta( $post_id, self::MANIFEST_META, true ); $profile_signature = is_array( $raw_manifest ) ? sanitize_text_field( (string) ( $raw_manifest['profile_signature'] ?? '' ) ) : ''; $this->set_state( $post_id, 'ready', (string) $manifest['fingerprint'], (string) $manifest['storage_key'], count( $manifest['chunks'] ), count( $manifest['chunks'] ), '', 0, (string) $prepared['source_hash'], $profile_signature ); return; } $this->set_state( $post_id, 'missing', $this->fingerprint( $prepared, $profile_id ), '', 0, count( $prepared['chunks'] ), '', 0, (string) $prepared['source_hash'], $this->settings_signature( $settings, $profile_id ) ); } /** * Add split content/profile identity to manifests created by the first * cache betas. Migration is allowed only when the legacy combined identity * proves that both the current content and current profile still match. * * @param array $manifest Existing manifest. * @param array $prepared Prepared current article content. * @return array */ private function maybe_upgrade_manifest( int $post_id, array $manifest, array $prepared ): array { if ( ! empty( $manifest['content_hash'] ) && ! empty( $manifest['profile_signature'] ) ) { return $manifest; } $settings = AI_Narration_Admin::get_settings(); $profile_id = $this->content->narration_profile( $post_id, $settings ); $current_fingerprint = $this->fingerprint( $prepared, $profile_id ); if ( empty( $manifest['fingerprint'] ) || ! hash_equals( $current_fingerprint, (string) $manifest['fingerprint'] ) ) { return $manifest; } $manifest['content_hash'] = sanitize_text_field( (string) $prepared['source_hash'] ); $manifest['profile_signature'] = $this->settings_signature( $settings, $profile_id ); $manifest['narration_profile'] = $profile_id; update_post_meta( $post_id, self::MANIFEST_META, $manifest ); $state = $this->state( $post_id ); if ( ! empty( $state['fingerprint'] ) && hash_equals( (string) $manifest['fingerprint'], (string) $state['fingerprint'] ) ) { $state['content_hash'] = $manifest['content_hash']; $state['profile_signature'] = $manifest['profile_signature']; update_post_meta( $post_id, self::STATE_META, $state ); } return $manifest; } /** @param array $prepared */ private function fingerprint( array $prepared, string $profile_id = 'a' ): string { return hash( 'sha256', self::CACHE_SCHEMA . '|' . (string) $prepared['hash'] . '|' . $this->settings_signature( AI_Narration_Admin::get_settings(), $profile_id ) ); } /** @param array $settings */ private function settings_signature( array $settings, string $profile_id = 'a' ): string { $profile = $this->profile( $settings, 'a' ); return hash( 'sha256', (string) wp_json_encode( array( 'model' => $profile['model'], 'voice' => $profile['voice'], 'instructions' => $profile['instructions'], 'read_h2_headings' => ! empty( $settings['read_h2_headings'] ), ) ) ); } /** * @param array $settings * @return array{provider:string,model:string,voice:string,instructions:string,format:string,mime:string,extension:string} */ private function profile( array $settings, string $profile_id = 'a' ): array { $settings = wp_parse_args( $settings, AI_Narration_Admin::default_settings() ); $profile_id = 'a'; $model = array_key_exists( (string) ( $settings['model'] ?? '' ), AI_Narration_Admin::models() ) ? (string) $settings['model'] : 'gpt-4o-mini-tts'; $voice = array_key_exists( (string) ( $settings['voice'] ?? '' ), AI_Narration_Admin::voices() ) ? (string) $settings['voice'] : 'cedar'; return array( 'provider' => 'openai', 'model' => $model, 'voice' => $voice, 'instructions' => (string) ( $settings['instructions'] ?? '' ), 'format' => 'aac', 'mime' => 'audio/aac', 'extension' => 'aac', ); } /** @return array{dir:string,url:string} */ private function cache_root(): array { $uploads = wp_upload_dir(); return array( 'dir' => trailingslashit( (string) $uploads['basedir'] ) . 'ai-narration', 'url' => trailingslashit( (string) $uploads['baseurl'] ) . 'ai-narration', ); } /** @return array{dir:string,url:string} */ private function location( int $post_id, string $fingerprint ): array { $root = $this->cache_root(); $relative = absint( $post_id ) . '/' . sanitize_file_name( $fingerprint ); return array( 'dir' => trailingslashit( $root['dir'] ) . $relative, 'url' => trailingslashit( $root['url'] ) . $relative ); } private function chunk_filename( int $index, string $extension = 'aac' ): string { $extension = in_array( $extension, array( 'aac', 'mp3', 'wav' ), true ) ? $extension : 'aac'; return sprintf( 'chunk-%02d.%s', $index, $extension ); } private function publish_manifest( int $post_id, string $fingerprint, string $storage_key, int $total, string $profile_id, string $content_hash, string $profile_signature ): void { $settings = AI_Narration_Admin::get_settings(); $profile = $this->profile( $settings, $profile_id ); $chunks = array(); for ( $index = 0; $index < $total; $index++ ) { $chunks[] = $this->chunk_filename( $index, $profile['extension'] ); } $previous_manifest = get_post_meta( $post_id, self::MANIFEST_META, true ); $previous_storage_key = ''; if ( is_array( $previous_manifest ) && ! empty( $previous_manifest['fingerprint'] ) ) { $previous_storage_key = ! empty( $previous_manifest['storage_key'] ) ? sanitize_file_name( (string) $previous_manifest['storage_key'] ) : sanitize_file_name( (string) $previous_manifest['fingerprint'] ); } update_post_meta( $post_id, self::MANIFEST_META, array( 'fingerprint' => $fingerprint, 'storage_key' => $storage_key, 'content_hash' => sanitize_text_field( $content_hash ), 'profile_signature' => sanitize_text_field( $profile_signature ), 'narration_profile' => $profile_id, 'chunks' => $chunks, 'created' => time(), 'provider' => $profile['provider'], 'model' => $profile['model'], 'voice' => $profile['voice'], 'format' => $profile['format'], ) ); $this->set_state( $post_id, 'ready', $fingerprint, $storage_key, $total, $total, '', 0, $content_hash, $profile_signature ); wp_clear_scheduled_hook( self::WATCHDOG_HOOK, array( $post_id, $fingerprint, $storage_key ) ); $this->remove_old_versions( $post_id, $storage_key, $previous_storage_key ); } /** @return array{audio:string,mime:string,extension:string,provider:string,model:string,voice:string,language:string,format:string} */ private function request_audio( string $text, string $profile_id ): array { $settings = AI_Narration_Admin::get_settings(); $profile = $this->profile( $settings, $profile_id ); $model = $profile['model']; $voice = $profile['voice']; $body = array( 'model' => $model, 'input' => $text, 'voice' => $voice, 'response_format' => 'aac' ); $instructions = trim( (string) $profile['instructions'] ); if ( str_starts_with( $model, 'gpt-4o-mini-tts' ) && '' !== $instructions ) { $body['instructions'] = $instructions; } $response = wp_remote_post( self::OPENAI_ENDPOINT, array( 'timeout' => 90, 'redirection' => 0, 'sslverify' => true, 'limit_response_size' => 12 * MB_IN_BYTES, 'headers' => array( 'Authorization' => 'Bearer ' . AI_Narration_Admin::get_api_key(), 'Content-Type' => 'application/json', 'Accept' => 'audio/aac' ), 'body' => wp_json_encode( $body ), 'data_format' => 'body', ) ); if ( is_wp_error( $response ) ) { throw AI_Narration_Provider_Exception::transient( 'OpenAI could not be reached: ' . sanitize_key( $response->get_error_code() ) ); } $status = (int) wp_remote_retrieve_response_code( $response ); $audio = (string) wp_remote_retrieve_body( $response ); $content_type = (string) wp_remote_retrieve_header( $response, 'content-type' ); if ( 200 !== $status ) { // The response object and body are parsed into a sanitised exception message; neither is output here. // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped throw AI_Narration_Provider_Exception::from_http_response( 'OpenAI', $response, $audio ); } if ( ! AI_Narration_Audio_Validator::is_valid( $audio, 'aac', $content_type ) ) { throw AI_Narration_Provider_Exception::transient( 'OpenAI returned an invalid audio response (HTTP 200).' ); } return array( 'audio' => $audio, 'mime' => 'audio/aac', 'extension' => 'aac', 'provider' => 'openai', 'model' => $model, 'voice' => $voice, 'language' => '', 'format' => 'aac' ); } private function write_chunk( string $path, string $audio ): void { $temp = $path . '.part-' . wp_generate_password( 8, false, false ); $bytes = file_put_contents( $temp, $audio, LOCK_EX ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents if ( false === $bytes || $bytes !== strlen( $audio ) || ! rename( $temp, $path ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename if ( is_file( $temp ) ) { wp_delete_file( $temp ); } throw new RuntimeException( 'A completed narration chunk could not be written atomically.' ); } } private function ensure_cache_delivery_files(): void { $root = $this->cache_root()['dir']; if ( ! is_dir( $root ) ) { return; } $index = trailingslashit( $root ) . 'index.php'; if ( ! is_file( $index ) ) { file_put_contents( $index, "\n\nHeader set Cache-Control \"public, max-age=31536000, immutable\"\n\n\n"; $previous_contents = "\nAddType audio/aac .aac\n\n\n\nHeader set Cache-Control \"public, max-age=31536000, immutable\"\n\n\n"; $contents = "\nAddType audio/aac .aac\nAddType audio/mpeg .mp3\nAddType audio/wav .wav\n\n\n\nHeader set Cache-Control \"public, max-age=31536000, immutable\"\n\n\n"; if ( ! is_file( $rules ) || in_array( (string) file_get_contents( $rules ), array( $legacy_contents, $previous_contents ), true ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents file_put_contents( $rules, $contents, LOCK_EX ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents } } private function set_state( int $post_id, string $status, string $fingerprint, string $storage_key, int $completed, int $total, string $error, int $attempts = 0, string $content_hash = '', string $profile_signature = '', int $next_attempt = 0 ): void { update_post_meta( $post_id, self::STATE_META, array( 'status' => sanitize_key( $status ), 'fingerprint' => sanitize_text_field( $fingerprint ), 'storage_key' => sanitize_file_name( $storage_key ), 'completed' => max( 0, $completed ), 'total' => max( 0, $total ), 'error' => sanitize_text_field( $error ), 'attempts' => max( 0, $attempts ), 'content_hash' => sanitize_text_field( $content_hash ), 'profile_signature' => sanitize_text_field( $profile_signature ), 'next_attempt' => max( 0, $next_attempt ), 'updated' => time(), ) ); } private function remove_old_versions( int $post_id, string $keep, string $preserve = '' ): void { $root = $this->cache_root(); $post_dir = trailingslashit( $root['dir'] ) . absint( $post_id ); $entries = is_dir( $post_dir ) ? glob( trailingslashit( $post_dir ) . '*' ) : false; if ( ! is_array( $entries ) ) { return; } foreach ( $entries as $entry ) { $name = basename( $entry ); if ( is_dir( $entry ) && $name !== $keep && ( '' === $preserve || $name !== $preserve ) ) { $this->delete_tree( $entry ); } } } private function cancel_article_jobs( int $post_id ): void { $state = $this->state( $post_id ); $fingerprint = sanitize_text_field( (string) ( $state['fingerprint'] ?? '' ) ); $storage_key = sanitize_file_name( (string) ( $state['storage_key'] ?? '' ) ); if ( '' !== $fingerprint && '' !== $storage_key ) { $args = array( $post_id, $fingerprint, $storage_key ); wp_clear_scheduled_hook( self::CRON_HOOK, $args ); wp_clear_scheduled_hook( self::WATCHDOG_HOOK, $args ); } wp_clear_scheduled_hook( self::DISCOVERY_HOOK, array( $post_id, 0 ) ); wp_clear_scheduled_hook( self::DISCOVERY_HOOK, array( $post_id, 1 ) ); $manifest = get_post_meta( $post_id, self::MANIFEST_META, true ); $active_storage_key = ''; if ( is_array( $manifest ) && ! empty( $manifest['fingerprint'] ) ) { $active_storage_key = ! empty( $manifest['storage_key'] ) ? sanitize_file_name( (string) $manifest['storage_key'] ) : sanitize_file_name( (string) $manifest['fingerprint'] ); } if ( '' !== $storage_key && $storage_key !== $active_storage_key ) { $this->delete_tree( $this->location( $post_id, $storage_key )['dir'] ); } } /** * Check whether a settings snapshot has an effective narration credential. * * A configured constant remains authoritative even when the saved key is * deliberately cleared from WordPress. * * @param array $settings Settings snapshot. */ private function settings_have_narration_credentials( array $settings ): bool { if ( defined( 'AI_NARRATION_OPENAI_API_KEY' ) && is_string( AI_NARRATION_OPENAI_API_KEY ) && '' !== trim( AI_NARRATION_OPENAI_API_KEY ) ) { return true; } return '' !== trim( (string) ( $settings['api_key'] ?? '' ) ); } /** * Cancel transient article work when the effective credential is removed. */ private function cancel_jobs_after_credentials_removed(): void { $post_ids = get_posts( array( 'post_type' => 'any', 'post_status' => 'any', 'posts_per_page' => -1, 'fields' => 'ids', 'meta_key' => self::STATE_META, 'no_found_rows' => true, ) ); foreach ( $post_ids as $post_id ) { $post_id = absint( $post_id ); $status = sanitize_key( (string) ( $this->state( $post_id )['status'] ?? '' ) ); if ( in_array( $status, array( 'queued', 'generating', 'regenerating', 'retrying' ), true ) ) { $this->cancel_generation_preserving_audio( $post_id ); } } } /** * Cancel replacement work without deleting a valid stored narration. */ private function cancel_generation_preserving_audio( int $post_id ): void { try { $prepared = $this->content->prepare( $post_id ); $this->reconcile_cache_state( $post_id, $prepared ); } catch ( Throwable $exception ) { $this->cancel_article_jobs( $post_id ); delete_post_meta( $post_id, self::STATE_META ); } } private function remove_article_cache( int $post_id ): void { $this->cancel_article_jobs( $post_id ); $this->delete_tree( trailingslashit( $this->cache_root()['dir'] ) . absint( $post_id ) ); delete_post_meta( $post_id, self::MANIFEST_META ); delete_post_meta( $post_id, self::STATE_META ); } private function delete_tree( string $directory ): void { if ( '' === $directory || ! is_dir( $directory ) ) { return; } $items = scandir( $directory ); if ( ! is_array( $items ) ) { return; } foreach ( $items as $item ) { if ( '.' === $item || '..' === $item ) { continue; } $path = $directory . DIRECTORY_SEPARATOR . $item; if ( is_dir( $path ) ) { $this->delete_tree( $path ); } else { wp_delete_file( $path ); } } // The directory is plugin-owned and empty after each child has been removed with wp_delete_file(). // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged,WordPress.WP.AlternativeFunctions.file_system_operations_rmdir @rmdir( $directory ); } }