'index', 'stack' => array( array( 'directory' => '', 'offset' => 0, 'last' => null ) ), 'cursor' => 0, 'indexed_files' => 0, 'processed' => 0, 'next_run' => 0, 'error' => '' ); } private static function state() { $state = get_option( self::STATE, array() ); if ( ! is_array( $state ) || ! isset( $state['phase'] ) || ( 'complete' === $state['phase'] && '1' !== get_option( 'accessibility_audio_tts_private_audio_migration', '' ) ) ) { return self::initial_state(); } return $state; } public static function index_complete() { return 'index' !== self::state()['phase']; } /** A failed query is never an empty queue or proof that migration finished. */ private static function read_ids( $query ) { global $wpdb; $ids = $wpdb->get_col( $query ); if ( $wpdb->last_error || ! is_array( $ids ) ) { throw new \RuntimeException( 'The audio migration database scan failed. Check the database and retry migration.' ); } return $ids; } private static function pending_count() { global $wpdb; $count = $wpdb->get_var( $wpdb->prepare( "SELECT COUNT(*) FROM {$wpdb->postmeta} WHERE meta_key = %s", self::RETRY_META ) ); if ( $wpdb->last_error || null === $count || ! is_numeric( $count ) ) { throw new \RuntimeException( 'The audio migration retry count could not be read. Check the database and retry migration.' ); } return (int) $count; } private static function save_state( $state ) { global $wpdb; update_option( self::STATE, $state, false ); if ( $wpdb->last_error ) { throw new \RuntimeException( 'The audio migration checkpoint could not be saved. Check the database and retry migration.' ); } wp_cache_delete( self::STATE, 'options' ); if ( get_option( self::STATE ) !== $state || $wpdb->last_error ) { throw new \RuntimeException( 'The audio migration checkpoint could not be saved. Check the database and retry migration.' ); } } private static function save_retry( $id, $when = null ) { global $wpdb; if ( null === $when ) { delete_post_meta( $id, self::RETRY_META ); } else { update_post_meta( $id, self::RETRY_META, $when ); } if ( $wpdb->last_error ) { throw new \RuntimeException( 'The audio migration retry could not be saved. Check the database and retry migration.' ); } wp_cache_delete( $id, 'post_meta' ); $actual = get_post_meta( $id, self::RETRY_META, true ); if ( $wpdb->last_error || ( null === $when ? '' !== $actual : (string) $actual !== (string) $when ) ) { throw new \RuntimeException( 'The audio migration retry could not be saved. Check the database and retry migration.' ); } } public static function status() { $state = self::state(); $pending = null; try { $pending = self::pending_count(); } catch ( \Throwable $error ) { $state['error'] = $error->getMessage(); } $complete = 'complete' === $state['phase'] && 0 === $pending && empty( $state['error'] ); return array( 'complete' => $complete, 'state' => $complete ? 'complete' : ( $pending || ! empty( $state['error'] ) ? 'needs_attention' : 'pending' ), 'phase' => $state['phase'], 'processed' => (int) $state['processed'], 'indexed_files' => (int) $state['indexed_files'], 'cursor' => (int) $state['cursor'], 'pending_failures' => $pending, 'next_run' => (int) $state['next_run'], 'error' => $state['error'] ?? '', 'message' => $complete ? __( 'Existing audio migration is complete.', 'accessibility-audio-tts-text-to-speech-for-articles' ) : __( 'Existing audio migration is pending. Some old static audio URLs may still be public. Finish migration during maintenance before reopening protected content.', 'accessibility-audio-tts-text-to-speech-for-articles' ) ); } private static function schedule() { if ( ! wp_next_scheduled( self::HOOK ) ) { wp_schedule_single_event( time() + 60, self::HOOK ); } } /** One request processes at most 250 directory entries and 20 posts by default. */ public static function run_batch( $force = false, $post_limit = 20, $file_limit = 250 ) { $state = self::state(); if ( 'complete' === $state['phase'] ) { return self::status(); } if ( ! $force && $state['next_run'] > time() ) { self::schedule(); return self::status(); } // Reserved post 0 is never a generated article. Reuse the atomic token-owned lease. $lease = Generation_Lease::acquire( 0 ); if ( ! $lease ) { $status = self::status(); $status['busy'] = true; return $status; } $deadline = microtime( true ) + 3; $post_limit = max( 1, min( 100, (int) $post_limit ) ); $file_limit = max( 1, min( 1000, (int) $file_limit ) ); $failed = 0; $batch_error = ''; try { // Re-read only after exclusive ownership; an earlier request may have advanced it. $state = self::state(); $checkpoint = $state; $state['error'] = ''; if ( 'index' === $state['phase'] ) { $checkpoint = $state; try { self::index_files( $state, $file_limit, $deadline, $lease ); } catch ( \Throwable $error ) { $state = $checkpoint; throw $error; } } if ( 'posts' === $state['phase'] && microtime( true ) < $deadline ) { global $wpdb; $ids = self::read_ids( $wpdb->prepare( "SELECT DISTINCT post_id FROM {$wpdb->postmeta} WHERE post_id > %d AND meta_key IN ('accessibility_audio_tts_url','accessibility_audio_tts_file_data','accessibility_audio_tts_generated','_accessibility_audio_tts_legacy_files') ORDER BY post_id ASC LIMIT %d", $state['cursor'], $post_limit ) ); foreach ( $ids as $id ) { if ( microtime( true ) >= $deadline || ! $lease->renew() ) { break; } if ( ! self::migrate_post( (int) $id ) ) { ++$failed; } $state['cursor'] = (int) $id; ++$state['processed']; } if ( empty( $ids ) ) { $state['phase'] = 'retry'; } } if ( 'retry' === $state['phase'] && microtime( true ) < $deadline ) { global $wpdb; $due = $force ? PHP_INT_MAX : time(); $ids = self::read_ids( $wpdb->prepare( "SELECT DISTINCT post_id FROM {$wpdb->postmeta} WHERE meta_key = %s AND CAST(meta_value AS UNSIGNED) <= %d ORDER BY post_id ASC LIMIT %d", self::RETRY_META, $due, $post_limit ) ); foreach ( $ids as $id ) { if ( microtime( true ) >= $deadline || ! $lease->renew() ) { break; } if ( ! self::migrate_post( (int) $id ) ) { ++$failed; } } $pending = self::pending_count(); if ( 0 === $pending ) { $state['phase'] = 'complete'; } } $state['next_run'] = time() + 10; if ( $lease->owns() ) { self::save_state( $state ); if ( 'complete' === $state['phase'] ) { update_option( 'accessibility_audio_tts_private_audio_migration', '1', false ); wp_clear_scheduled_hook( self::HOOK ); } else { self::schedule(); } } } catch ( \Throwable $error ) { $state = $checkpoint ?? $state; $batch_error = $error->getMessage(); $state['error'] = $batch_error; $state['next_run'] = time() + 300; if ( $lease->owns() ) { delete_option( 'accessibility_audio_tts_private_audio_migration' ); update_option( self::STATE, $state, false ); self::schedule(); } } finally { $lease->release(); } $status = self::status(); $status['failed_in_batch'] = $failed; if ( $batch_error ) { $status['complete'] = false; $status['state'] = 'needs_attention'; $status['error'] = $batch_error; } return $status; } private static function index_files( &$state, $limit, $deadline, $lease ) { $uploads = wp_upload_dir(); $root = realpath( $uploads['basedir'] . '/accessibility-audio-tts' ); if ( false === $root ) { $state['stack'] = array(); $state['phase'] = 'posts'; return; } $count = 0; $indexed = array(); $walkers = array(); while ( $state['stack'] && $count < $limit && microtime( true ) < $deadline ) { $key = count( $state['stack'] ) - 1; $frame = $state['stack'][ $key ]; $directory = $root . ( '' === $frame['directory'] ? '' : '/' . $frame['directory'] ); $real = realpath( $directory ); if ( ! $real || is_link( $directory ) || 0 !== strpos( wp_normalize_path( $real ) . '/', rtrim( wp_normalize_path( $root ), '/' ) . '/' ) ) { array_pop( $state['stack'] ); continue; } $offset = (int) $frame['offset']; // Anchor the cursor to its previous filename. Deletions between requests reset // only this directory, so a protection transition cannot make us skip entries. if ( ! isset( $walkers[ $directory ] ) ) { $iterator = new \DirectoryIterator( $directory ); if ( $offset > 0 ) { try { $iterator->seek( $offset - 1 ); if ( ! $iterator->valid() || $iterator->getFilename() !== $frame['last'] ) { $offset = 0; } } catch ( \OutOfBoundsException $error ) { $offset = 0; } } try { $iterator->seek( $offset ); } catch ( \OutOfBoundsException $error ) { array_pop( $state['stack'] ); continue; } $walkers[ $directory ] = $iterator; } $iterator = $walkers[ $directory ]; if ( ! $iterator->valid() ) { array_pop( $state['stack'] ); continue; } $name = $iterator->getFilename(); $entry = $iterator->getFileInfo(); $iterator->next(); $state['stack'][ $key ]['offset'] = $offset + 1; $state['stack'][ $key ]['last'] = $name; ++$count; if ( '.' === $name || '..' === $name || $entry->isLink() ) { continue; } $relative = ltrim( $frame['directory'] . '/' . $name, '/' ); if ( $entry->isDir() ) { $state['stack'][] = array( 'directory' => $relative, 'offset' => 0, 'last' => null ); continue; } if ( ! $entry->isFile() || ! in_array( strtolower( $entry->getExtension() ), array( 'mp3', 'wav', 'ogg' ), true ) ) { continue; } if ( preg_match( '/^(?:accessibility_audio_tts_(\d+)_|post_(\d+)_chunk_|post[-_](\d+)[-_]audio|audio_(\d+)\.)/', $name, $matches ) ) { $id = max( array_map( 'intval', array_slice( $matches, 1 ) ) ); if ( $id > 0 && get_post( $id ) ) { $indexed[ $id ][] = $relative; ++$state['indexed_files']; } } } foreach ( $indexed as $id => $files ) { if ( ! $lease->renew() ) { throw new \RuntimeException( 'The audio migration lease changed owner. This batch stopped.' ); } $old = get_post_meta( $id, self::FILES_META, true ); $expected = array_values( array_unique( array_merge( is_array( $old ) ? $old : array(), $files ) ) ); update_post_meta( $id, self::FILES_META, $expected ); if ( get_post_meta( $id, self::FILES_META, true ) !== $expected ) { throw new \RuntimeException( 'The legacy audio index could not be saved. Check the database before continuing migration.' ); } } if ( ! $state['stack'] ) { $state['phase'] = 'posts'; } } private static function migrate_post( $id ) { $post = get_post( $id ); if ( ! $post || ! Audio_Access::is_restricted( $post ) ) { self::save_retry( $id ); return true; } $lease = Generation_Lease::acquire( $id ); if ( ! $lease ) { self::save_retry( $id, time() + 60 ); return false; } $previous = $lease->activate(); try { Audio_Access::protect_post( $id, false, true ); self::save_retry( $id ); return true; } catch ( \Throwable $error ) { self::save_retry( $id, time() + 300 ); Audio_Access::remember_migration_error( $id, $error ); return false; } finally { $lease->deactivate( $previous ); $lease->release(); } } }