'object', self::STATE_META => 'string' ) as $key => $type ) { register_post_meta( '', $key, array( 'type' => $type, 'single' => true, 'show_in_rest' => false, 'auth_callback' => static function () { return current_user_can( 'manage_options' ); } ) ); } add_action( self::CRON_HOOK, array( __CLASS__, 'process_next' ) ); add_action( 'admin_init', array( __CLASS__, 'ensure_scheduled' ), 10, 0 ); add_action( 'before_delete_post', array( __CLASS__, 'forget' ) ); } /** Query only the next bounded group, including custom public content types selected in settings. */ public static function query_args( string $filter = 'all', string $model = '', int $cursor = 0, int $limit = self::MAX_BATCH, int $maximum = 0 ): array { $meta = array( 'relation' => 'AND', array( 'key' => 'accessibility_audio_tts_generated', 'value' => '1' ) ); if ( 'unknown' === $filter ) { $meta[] = array( 'relation' => 'OR', array( 'key' => 'accessibility_audio_tts_model', 'compare' => 'NOT EXISTS' ), array( 'key' => 'accessibility_audio_tts_model', 'value' => '' ), array( 'key' => 'accessibility_audio_tts_model', 'value' => ElevenLabs_Models::ids(), 'compare' => 'NOT IN' ) ); } elseif ( 'different' === $filter ) { $meta[] = array( 'key' => 'accessibility_audio_tts_model', 'value' => array_values( array_diff( ElevenLabs_Models::ids(), array( $model ) ) ), 'compare' => 'IN' ); } return array( 'post_type' => self::post_types(), 'post_status' => 'publish', 'posts_per_page' => min( self::MAX_BATCH, max( 1, $limit ) ), 'fields' => 'ids', 'orderby' => 'ID', 'order' => 'ASC', 'meta_query' => $meta, 'no_found_rows' => false, 'update_post_term_cache' => false, 'aat_rebuild_cursor' => $cursor, 'aat_rebuild_maximum' => $maximum ); } private static function post_types(): array { $types = get_option( 'accessibility_audio_tts_post_types', array( 'post' ) ); $types = is_array( $types ) ? array_filter( $types, static function ( $type ) { $object = is_string( $type ) ? get_post_type_object( $type ) : null; return $object && $object->public; } ) : array(); return $types ? array_values( $types ) : array( '__aat_no_content_type__' ); } public static function matches( int $post_id, string $filter, string $target_model ): bool { $recording = Recording_Metadata::describe( $post_id ); if ( ! $recording['has_recording'] ) { return false; } $known = ElevenLabs_Models::is_supported( $recording['model_id'] ?? null ); return 'all' === $filter || ( 'unknown' === $filter && ! $known ) || ( 'different' === $filter && $known && $recording['model_id'] !== $target_model ); } /** A source fingerprint prevents queued jobs from silently rebuilding a later edit. */ public static function source_signature( \WP_Post $post ): string { return hash( 'sha256', wp_json_encode( array( $post->post_title, $post->post_content, $post->post_excerpt, $post->post_status, $post->post_password, $post->post_type ) ) ); } private static function authorize( int $actor, int $post_id = 0 ): bool { return $actor > 0 && user_can( $actor, 'manage_options' ) && ( ! $post_id || user_can( $actor, 'edit_post', $post_id ) ); } private static function ids( $input ) { if ( ! is_array( $input ) || count( $input ) > self::MAX_BATCH ) { return new \WP_Error( 'invalid_selection', __( 'Select no more than 50 recordings at a time.', 'accessibility-audio-tts-text-to-speech-for-articles' ) ); } $ids = array(); foreach ( $input as $value ) { if ( ! is_scalar( $value ) || ! preg_match( '/^[1-9][0-9]*$/D', (string) $value ) ) { return new \WP_Error( 'invalid_selection', __( 'Invalid recording selection.', 'accessibility-audio-tts-text-to-speech-for-articles' ) ); } $id = absint( $value ); if ( (string) $id !== (string) $value ) { return new \WP_Error( 'invalid_selection', __( 'Invalid recording selection.', 'accessibility-audio-tts-text-to-speech-for-articles' ) ); } $ids[] = $id; } return array_values( array_unique( $ids ) ); } private static function eligibility( int $post_id, int $actor, string $model, string $filter = 'all', ?string $voice_override = null ) { $post = get_post( $post_id ); if ( ! $post || 'publish' !== $post->post_status || ! in_array( $post->post_type, self::post_types(), true ) || ! self::authorize( $actor, $post_id ) ) { return __( 'This article is unavailable or you cannot edit it.', 'accessibility-audio-tts-text-to-speech-for-articles' ); } if ( ! Privacy_Settings::can_process_post( $post ) ) { return __( 'Cloud processing is disabled for this restricted article.', 'accessibility-audio-tts-text-to-speech-for-articles' ); } $recording = Recording_Metadata::describe( $post_id ); if ( ! $recording['has_recording'] || ! self::matches( $post_id, $filter, $model ) ) { return __( 'The recording is unavailable or no longer matches this filter.', 'accessibility-audio-tts-text-to-speech-for-articles' ); } if ( $recording['model_id'] === $model ) { return __( 'This recording already uses the selected model.', 'accessibility-audio-tts-text-to-speech-for-articles' ); } $voice = null !== $voice_override ? $voice_override : ( $recording['voice_id'] ?: get_option( 'accessibility_audio_tts_voice_id', '' ) ); if ( ! is_string( $voice ) || ! preg_match( '/^[a-zA-Z0-9_-]{1,128}$/D', $voice ) ) { return __( 'Select a valid voice before rebuilding this recording.', 'accessibility-audio-tts-text-to-speech-for-articles' ); } return array( 'post_id' => $post_id, 'recording_signature' => $recording['recording_signature'], 'source_signature' => self::source_signature( $post ), 'voice_id' => $voice, 'model_id' => $model, 'previous_model' => $recording['model_id'] ); } /** Read-only planning: a signed preview pins both the file and the per-recording voice. */ public static function preview( array $input, int $actor ) { if ( ! self::authorize( $actor ) ) { return new \WP_Error( 'forbidden', __( 'Access denied.', 'accessibility-audio-tts-text-to-speech-for-articles' ) ); } $model = $input['model_id'] ?? ''; $filter = $input['filter'] ?? 'all'; $selection = $input['selection'] ?? 'selected'; if ( ! ElevenLabs_Models::is_supported( $model ) || ! is_string( $filter ) || ! is_string( $selection ) || ! in_array( $filter, array( 'all', 'different', 'unknown' ), true ) || ! in_array( $selection, array( 'selected', 'matching' ), true ) ) { return new \WP_Error( 'invalid_preview', __( 'Invalid rebuild options.', 'accessibility-audio-tts-text-to-speech-for-articles' ) ); } $cursor = absint( $input['cursor'] ?? 0 ); $maximum = absint( $input['snapshot_max_id'] ?? 0 ); $has_more = false; $matched_total = 0; if ( 'matching' === $selection ) { global $wpdb; if ( ! $maximum ) { $maximum = (int) $wpdb->get_var( "SELECT MAX(ID) FROM {$wpdb->posts}" ); } $where = static function ( $sql, $query ) use ( $wpdb ) { if ( ! $query->get( 'aat_rebuild_maximum' ) ) { return $sql; } return $sql . $wpdb->prepare( " AND {$wpdb->posts}.ID > %d AND {$wpdb->posts}.ID <= %d", (int) $query->get( 'aat_rebuild_cursor' ), (int) $query->get( 'aat_rebuild_maximum' ) ); }; add_filter( 'posts_where', $where, 10, 2 ); try { $query = new \WP_Query( self::query_args( $filter, $model, $cursor, self::MAX_BATCH, $maximum ) ); } finally { remove_filter( 'posts_where', $where, 10 ); } $ids = array_map( 'intval', $query->posts ); $matched_total = (int) $query->found_posts; $has_more = $matched_total > count( $ids ); } else { $ids = self::ids( $input['post_ids'] ?? array() ); if ( is_wp_error( $ids ) ) { return $ids; } $matched_total = count( $ids ); } $entries = array(); $skipped = array(); $counts = array(); foreach ( $ids as $id ) { $eligible = self::eligibility( $id, $actor, $model, $filter ); if ( ! is_array( $eligible ) ) { $skipped[] = array( 'post_id' => $id, 'reason' => $eligible ); continue; } $entries[] = $eligible; $previous = $eligible['previous_model'] ?: 'unknown'; $counts[ $previous ] = ( $counts[ $previous ] ?? 0 ) + 1; } $chatgpt = array( 'enabled' => Privacy_Settings::openai_enabled(), 'model_id' => sanitize_text_field( get_option( 'accessibility_audio_tts_chatgpt_model', 'gpt-5' ) ) ); $payload = array( 'version' => 1, 'blog_id' => get_current_blog_id(), 'actor_id' => $actor, 'model_id' => $model, 'issued_at' => time(), 'expires_at' => time() + self::PREVIEW_TTL, 'entries' => $entries, 'chatgpt_snapshot' => $chatgpt, 'snapshot_max_id' => $maximum, 'salt' => wp_generate_password( 20, false, false ) ); $encoded = rtrim( strtr( base64_encode( wp_json_encode( $payload ) ), '+/', '-_' ), '=' ); $token = $encoded . '.' . hash_hmac( 'sha256', $encoded, wp_salt( 'nonce' ) ); return array( 'preview_token' => $token, 'post_ids' => array_column( $entries, 'post_id' ), 'eligible_count' => count( $entries ), 'count' => count( $entries ), 'matched_total' => $matched_total, 'has_more' => $has_more, 'next_cursor' => $ids ? max( $ids ) : $cursor, 'snapshot_max_id' => $maximum, 'model_id' => $model, 'model_name' => ElevenLabs_Models::get( $model )['name'], 'skipped' => $skipped, 'model_counts' => $counts, 'background_enabled' => \Accessibility_Audio_TTS\Admin\Conversion_Processor::background_processing_enabled() ); } private static function decode_preview( string $token, int $actor ) { if ( strlen( $token ) > 65536 || 1 !== substr_count( $token, '.' ) ) { return null; } list( $encoded, $signature ) = explode( '.', $token, 2 ); if ( ! hash_equals( hash_hmac( 'sha256', $encoded, wp_salt( 'nonce' ) ), $signature ) ) { return null; } $decoded = base64_decode( strtr( $encoded, '-_', '+/' ), true ); $payload = is_string( $decoded ) ? json_decode( $decoded, true ) : null; if ( ! is_array( $payload ) || 1 !== ( $payload['version'] ?? 0 ) || get_current_blog_id() !== ( $payload['blog_id'] ?? 0 ) || $actor !== ( $payload['actor_id'] ?? 0 ) || ( $payload['expires_at'] ?? 0 ) < time() || ( $payload['issued_at'] ?? PHP_INT_MAX ) > time() + 60 || ! ElevenLabs_Models::is_supported( $payload['model_id'] ?? null ) || ! is_array( $payload['entries'] ?? null ) || count( $payload['entries'] ) > self::MAX_BATCH ) { return null; } return $payload; } /** Enqueue is idempotent for the exact reviewed token, including after a lost HTTP response. */ public static function enqueue( string $token, int $actor ) { if ( ! self::authorize( $actor ) ) { return new \WP_Error( 'forbidden', __( 'Access denied.', 'accessibility-audio-tts-text-to-speech-for-articles' ) ); } $payload = self::decode_preview( $token, $actor ); if ( ! $payload ) { return new \WP_Error( 'expired_preview', __( 'This preview expired or changed. Review the selection again before rebuilding.', 'accessibility-audio-tts-text-to-speech-for-articles' ) ); } if ( ! \Accessibility_Audio_TTS\Admin\Conversion_Processor::background_processing_enabled() ) { return new \WP_Error( 'background_disabled', __( 'Enable background processing before queueing recording rebuilds.', 'accessibility-audio-tts-text-to-speech-for-articles' ) ); } if ( ! get_option( 'accessibility_audio_tts_api_key', '' ) ) { return new \WP_Error( 'missing_api_key', __( 'Configure an ElevenLabs API key before rebuilding recordings.', 'accessibility-audio-tts-text-to-speech-for-articles' ) ); } $jobs = array(); $skipped = array(); foreach ( $payload['entries'] as $entry ) { $id = absint( $entry['post_id'] ); $job_id = substr( hash( 'sha256', $token . '|' . $id ), 0, 32 ); $lease = Generation_Lease::acquire( $id ); if ( ! $lease ) { $skipped[] = array( 'post_id' => $id, 'reason' => __( 'Audio generation is already in progress.', 'accessibility-audio-tts-text-to-speech-for-articles' ) ); continue; } try { $old = get_post_meta( $id, self::JOB_META, true ); if ( is_array( $old ) && ( $old['job_id'] ?? '' ) === $job_id ) { if ( self::reconcile_index( $id, $old ) ) { $jobs[] = self::public_job( $id, $old ); } else { $skipped[] = array( 'post_id' => $id, 'reason' => __( 'The rebuild queue could not be verified. Please retry the same reviewed selection.', 'accessibility-audio-tts-text-to-speech-for-articles' ) ); } continue; } if ( is_array( $old ) && in_array( $old['state'] ?? '', array( 'queued', 'processing' ), true ) ) { $skipped[] = array( 'post_id' => $id, 'reason' => __( 'This recording already has a rebuild job.', 'accessibility-audio-tts-text-to-speech-for-articles' ) ); continue; } $eligible = self::eligibility( $id, $actor, $payload['model_id'], 'all', $entry['voice_id'] ); if ( ! is_array( $eligible ) || ! hash_equals( $entry['recording_signature'], $eligible['recording_signature'] ) || ! hash_equals( $entry['source_signature'], $eligible['source_signature'] ) ) { $skipped[] = array( 'post_id' => $id, 'reason' => is_string( $eligible ) ? $eligible : __( 'The article or recording changed since the preview. Review it again.', 'accessibility-audio-tts-text-to-speech-for-articles' ) ); continue; } $job = array( 'schema' => 1, 'job_id' => $job_id, 'state' => 'queued', 'actor_id' => $actor, 'model_id' => $payload['model_id'], 'voice_id' => $entry['voice_id'], 'recording_signature' => $entry['recording_signature'], 'source_signature' => $entry['source_signature'], 'chatgpt_snapshot' => $payload['chatgpt_snapshot'], 'created_at' => time(), 'started_at' => 0, 'finished_at' => 0, 'error' => '' ); if ( ! self::save_job( $id, $job, $old ) ) { $skipped[] = array( 'post_id' => $id, 'reason' => __( 'The rebuild job could not be saved. Please retry.', 'accessibility-audio-tts-text-to-speech-for-articles' ) ); continue; } $jobs[] = self::public_job( $id, $job ); } finally { $lease->release(); } } self::ensure_scheduled(); return array( 'jobs' => $jobs, 'post_ids' => array_column( $jobs, 'post_id' ), 'queued_count' => count( array_filter( $jobs, static function ( $job ) { return 'queued' === $job['state']; } ) ), 'skipped' => $skipped ); } private static function save_job( int $post_id, array $job, $previous ): bool { $changed = $previous ? update_post_meta( $post_id, self::JOB_META, $job, $previous ) : add_post_meta( $post_id, self::JOB_META, $job, true ); if ( ! $changed && get_post_meta( $post_id, self::JOB_META, true ) !== $job ) { return false; } if ( self::reconcile_index( $post_id, $job ) ) { return true; } // Before dispatch, an undiscoverable job must not be acknowledged or run. // Roll back only this exact value: another worker may already have claimed it. if ( in_array( $job['state'], array( 'queued', 'processing' ), true ) ) { if ( is_array( $previous ) && $previous ) { $restored = update_post_meta( $post_id, self::JOB_META, $previous, $job ); if ( $restored || get_post_meta( $post_id, self::JOB_META, true ) === $previous ) { self::reconcile_index( $post_id, $previous ); } } elseif ( delete_post_meta( $post_id, self::JOB_META, $job ) ) { delete_post_meta( $post_id, self::STATE_META, $job['state'] ); } } // A completed paid job is authoritative even when its query index fails. // Status reads and later workers repair the index without paying again. try { accessibility_audio_tts_debug_log( 'Recording rebuild state index could not be persisted for post ' . $post_id, true ); } catch ( \Throwable $ignored ) { /* Diagnostics cannot turn an index failure into a paid retry. */ } return false; } /** The job is canonical; this scalar is only a verified discovery index. */ public static function reconcile_index( int $post_id, array $job ): bool { $state = $job['state'] ?? ''; if ( ! in_array( $state, array( 'queued', 'processing', 'completed', 'failed', 'cancelled' ), true ) ) { return false; } if ( get_post_meta( $post_id, self::STATE_META, true ) !== $state ) { update_post_meta( $post_id, self::STATE_META, $state ); } return get_post_meta( $post_id, self::STATE_META, true ) === $state; } private static function public_job( int $id, $job ): array { $job = is_array( $job ) ? $job : array(); return array( 'post_id' => $id, 'job_id' => $job['job_id'] ?? '', 'state' => $job['state'] ?? 'none', 'model_id' => $job['model_id'] ?? '', 'created_at' => (int) ( $job['created_at'] ?? 0 ), 'started_at' => (int) ( $job['started_at'] ?? 0 ), 'finished_at' => (int) ( $job['finished_at'] ?? 0 ), 'error' => $job['error'] ?? '' ); } public static function status( $input, int $actor ) { $ids = self::ids( $input ); if ( is_wp_error( $ids ) ) { return $ids; } if ( ! self::authorize( $actor ) ) { return new \WP_Error( 'forbidden', __( 'Access denied.', 'accessibility-audio-tts-text-to-speech-for-articles' ) ); } $jobs = array(); foreach ( $ids as $id ) { if ( ! self::authorize( $actor, $id ) ) { continue; } $job = get_post_meta( $id, self::JOB_META, true ); $ready = is_array( $job ) ? self::reconcile_index( $id, $job ) : true; $item = self::public_job( $id, $job ); $item['queue_ready'] = $ready; if ( ! $ready && 'queued' === $item['state'] && ! $item['error'] ) { $item['error'] = __( 'The rebuild queue could not be verified. Please retry the same reviewed selection.', 'accessibility-audio-tts-text-to-speech-for-articles' ); } $jobs[] = $item; } self::ensure_scheduled(); return array( 'jobs' => $jobs ); } public static function cancel( $input, int $actor ) { $ids = self::ids( $input ); if ( is_wp_error( $ids ) ) { return $ids; } if ( ! self::authorize( $actor ) ) { return new \WP_Error( 'forbidden', __( 'Access denied.', 'accessibility-audio-tts-text-to-speech-for-articles' ) ); } $cancelled = 0; foreach ( $ids as $id ) { if ( ! self::authorize( $actor, $id ) ) { continue; } $old = get_post_meta( $id, self::JOB_META, true ); if ( ! is_array( $old ) || 'queued' !== ( $old['state'] ?? '' ) ) { continue; } $job = $old; $job['state'] = 'cancelled'; $job['finished_at'] = time(); unset( $job['actor_id'] ); if ( self::save_job( $id, $job, $old ) || get_post_meta( $id, self::JOB_META, true ) === $job ) { ++$cancelled; } } return array_merge( self::status( $ids, $actor ), array( 'cancelled_count' => $cancelled ) ); } private static function pending_ids( array $states, int $limit = 1 ): array { $query = new \WP_Query( array( 'post_type' => array_values( get_post_types() ), 'post_status' => array_values( get_post_stati() ), 'fields' => 'ids', 'posts_per_page' => $limit, 'orderby' => 'ID', 'order' => 'ASC', 'no_found_rows' => true, 'update_post_term_cache' => false, 'meta_query' => array( array( 'key' => self::STATE_META, 'value' => $states, 'compare' => 'IN' ) ) ) ); return array_map( 'intval', $query->posts ); } private static function interval_seconds(): int { return max( MINUTE_IN_SECONDS, min( HOUR_IN_SECONDS, absint( get_option( 'accessibility_audio_tts_processing_interval', 10 ) ) * MINUTE_IN_SECONDS ) ); } /** Durable pacing survives reloads, status polling and an early cron invocation. */ public static function next_allowed_at(): int { $last = absint( get_option( self::LAST_STARTED_OPTION, 0 ) ); return $last > 0 ? $last + self::interval_seconds() : 0; } public static function ensure_scheduled( int $delay = 10 ): void { if ( ! self::pending_ids( array( 'queued', 'processing' ) ) ) { return; } $not_before = self::next_allowed_at(); $next = wp_next_scheduled( self::CRON_HOOK ); if ( $next && $next < $not_before ) { // Another admin request may have scheduled while the current cron // callback was running. Repair that event instead of trusting its time. wp_unschedule_event( $next, self::CRON_HOOK ); $next = wp_next_scheduled( self::CRON_HOOK ); } if ( ! $next ) { wp_schedule_single_event( max( time() + max( 10, $delay ), $not_before ), self::CRON_HOOK ); } } /** One paid job per callback. A crash never silently retries a potentially charged request. */ public static function process_next(): void { $worker = Generation_Lease::acquire( 0 ); if ( ! $worker ) { self::ensure_scheduled(); return; } $heartbeat = static function ( $pre ) use ( $worker ) { return $worker->renew() ? $pre : new \WP_Error( 'rebuild_worker_expired', 'The recording rebuild worker stopped because its lease changed.' ); }; try { foreach ( self::pending_ids( array( 'processing' ), 20 ) as $id ) { $old = get_post_meta( $id, self::JOB_META, true ); if ( ! is_array( $old ) ) { delete_post_meta( $id, self::STATE_META ); continue; } if ( 'processing' !== ( $old['state'] ?? '' ) ) { self::reconcile_index( $id, $old ); continue; } if ( (int) ( $old['started_at'] ?? 0 ) + self::STALE_AFTER < time() && ! Generation_Lease::is_busy( $id ) ) { $job = $old; $job['state'] = 'failed'; $job['finished_at'] = time(); $job['error'] = __( 'This rebuild was interrupted. Check the current recording before retrying.', 'accessibility-audio-tts-text-to-speech-for-articles' ); unset( $job['actor_id'] ); self::save_job( $id, $job, $old ); } } if ( ! \Accessibility_Audio_TTS\Admin\Conversion_Processor::background_processing_enabled() ) { return; } // Scheduling is advisory; this check under the worker lease is the // actual billing boundary, including manual or prematurely fired cron. if ( self::next_allowed_at() > time() ) { return; } $ids = self::pending_ids( array( 'queued' ) ); if ( ! $ids ) { return; } $id = $ids[0]; $old = get_post_meta( $id, self::JOB_META, true ); if ( ! is_array( $old ) ) { delete_post_meta( $id, self::STATE_META ); return; } if ( 'queued' !== ( $old['state'] ?? '' ) ) { self::reconcile_index( $id, $old ); return; } $job = $old; $job['state'] = 'processing'; $job['started_at'] = time(); if ( ! self::save_job( $id, $job, $old ) ) { return; } $error = ''; if ( ! ElevenLabs_Models::is_supported( $job['model_id'] ?? null ) ) { $error = __( 'The selected speech model is no longer supported.', 'accessibility-audio-tts-text-to-speech-for-articles' ); } else { $eligible = self::eligibility( $id, (int) ( $job['actor_id'] ?? 0 ), $job['model_id'], 'all', $job['voice_id'] ); if ( ! is_array( $eligible ) ) { $error = $eligible; } elseif ( ! hash_equals( $job['recording_signature'], $eligible['recording_signature'] ) || ! hash_equals( $job['source_signature'], $eligible['source_signature'] ) ) { $error = __( 'The article or recording changed after this rebuild was queued. Review it again.', 'accessibility-audio-tts-text-to-speech-for-articles' ); } } if ( ! $error ) { try { if ( ! $worker->renew() ) { throw new \RuntimeException( 'The rebuild worker lease changed.' ); } $started_at = time(); $reserved = Atomic_Options::update( self::LAST_STARTED_OPTION, static function ( $previous ) use ( $started_at ) { return max( absint( $previous ), $started_at ); }, 0 ); if ( (int) $reserved !== $started_at || (int) get_option( self::LAST_STARTED_OPTION, 0 ) !== $started_at || ! $worker->owns() ) { throw new \RuntimeException( 'The rebuild schedule reservation could not be verified.' ); } } catch ( \Throwable $e ) { $error = __( 'The rebuild schedule could not be saved safely. No provider request was sent. Please review and retry.', 'accessibility-audio-tts-text-to-speech-for-articles' ); } } if ( ! $error ) { $profile = array( 'model' => $job['model_id'], 'voice_id' => $job['voice_id'], 'actor_id' => (int) $job['actor_id'], 'chatgpt_snapshot' => $job['chatgpt_snapshot'], 'expected_recording_signature' => $job['recording_signature'], 'expected_source_signature' => $job['source_signature'] ); add_filter( 'pre_http_request', $heartbeat, 0, 1 ); try { $result = \Accessibility_Audio_TTS\Admin\Conversion_Processor::run_generation_job( $id, true, null, $profile ); } finally { remove_filter( 'pre_http_request', $heartbeat, 0 ); } $error = ! empty( $result['success'] ) ? '' : ( $result['error'] ?? __( 'The rebuild failed. Review the recording before retrying.', 'accessibility-audio-tts-text-to-speech-for-articles' ) ); } $finished = $job; $finished['state'] = $error ? 'failed' : 'completed'; $finished['finished_at'] = time(); $finished['error'] = mb_substr( sanitize_text_field( $error ), 0, 500, 'UTF-8' ); unset( $finished['actor_id'] ); self::save_job( $id, $finished, $job ); } catch ( \Throwable $e ) { // Retain processing state after an unexpected crash. Recovery requires review, // because a provider may already have charged for the interrupted request. accessibility_audio_tts_debug_log( 'Recording rebuild interrupted: ' . sanitize_text_field( $e->getMessage() ), true ); } finally { $worker->release(); self::ensure_scheduled(); } } public static function forget( int $post_id ): void { delete_post_meta( $post_id, self::JOB_META ); delete_post_meta( $post_id, self::STATE_META ); } }