5 * MINUTE_IN_SECONDS, 'display' => __( 'Every 5 minutes', 'aio-podcast-studio' ) ); return $schedules; } /** * Schedule an action if it's not already scheduled. * * @since 1.0.0 */ public function schedule_events() { if ( ! wp_next_scheduled( 'aiopod_schedule_every_five_minutes' ) ) { wp_schedule_event( time(), 'every-5-minutes', 'aiopod_schedule_every_five_minutes' ); } } /** * Called every 5 minutes. Run the auto import script. * * Episodes are processed in bounded batches (default 100 per run, filterable * via aiopod_cron_import_batch_size) to avoid exhausting PHP memory and * execution time on shared hosting. Batch state is tracked in the * import_cron_offset post meta. After each non-final batch import_next_schedule * is set to "now" so the next cron cycle continues immediately. The parsed * episode list is cached in a transient (aiopod_cron_feed_{id}) so only one * HTTP request is made per full import cycle regardless of episode count. * * The import log entry is written only on the final batch. Intermediate batches * are intentionally silent to avoid creating multiple partial entries in the * import history. * * @since 1.0.0 */ public function cron_event() { // Define the query $args = array( 'post_type' => 'aiopod_podcasts', 'post_status' => 'publish', 'posts_per_page' => 1, 'meta_query' => array( array( 'key' => 'import_next_schedule', 'value' => gmdate( 'Y-m-d H:i:s' ), 'compare' => '<=', 'type' => 'DATETIME' ) ), 'meta_key' => 'import_next_schedule', 'meta_type' => 'DATETIME', 'orderby' => 'meta_value', 'order' => 'ASC', 'fields' => 'ids', 'no_found_rows' => true, 'ignore_sticky_posts' => true, 'update_post_term_cache' => false, 'update_post_meta_cache' => false ); $aiopod_query = new WP_Query( $args ); // Find the podcast to import $podcast_id = ! empty( $aiopod_query->posts ) ? (int) $aiopod_query->posts[0] : 0; if ( empty( $podcast_id ) ) { return; } // Clear the next schedule to avoid overlapping runs delete_post_meta( $podcast_id, 'import_next_schedule' ); // Get import settings $appearance_settings = aiopod_get_option( 'aiopod_appearance_settings' ); $post_meta = get_post_meta( $podcast_id ); $rss_feed_url = isset( $post_meta['rss_feed_url'] ) ? aiopod_sanitize_url( $post_meta['rss_feed_url'][0] ) : ''; $update_existing_content = isset( $post_meta['update_existing_content'] ) ? (int) $post_meta['update_existing_content'][0] : 0; $auto_update_episodes = isset( $post_meta['auto_update_episodes'] ) ? (int) $post_meta['auto_update_episodes'][0] : 0; $auto_update_frequency = isset( $post_meta['auto_update_frequency'] ) ? (int) $post_meta['auto_update_frequency'][0] : 0; $author_id = $this->get_author_id( $podcast_id ); $set_featured_image = 0; if ( ! empty( $appearance_settings['enable_featured_images'] ) && ! empty( $appearance_settings['download_external_images'] ) ) { // Set featured image $set_featured_image = (int) get_post_meta( $podcast_id, 'set_featured_image', true ); } // Validate RSS feed URL if ( empty( $rss_feed_url ) ) { $this->add_import_log( $podcast_id, array( 'error' => __( 'RSS feed URL is missing.', 'aio-podcast-studio' ) ) ); return; } // Set time limit if ( function_exists( 'set_time_limit' ) ) { set_time_limit( 1200 ); } // Determine batch size and read the current batch offset. $batch_size = (int) apply_filters( 'aiopod_cron_import_batch_size', 100 ); if ( $batch_size < 1 ) { $batch_size = 100; } $batch_offset = (int) get_post_meta( $podcast_id, 'import_cron_offset', true ); $feed_transient_key = 'cron_feed_' . $podcast_id; // Parse the RSS feed or retrieve the cached episode list. if ( 0 === $batch_offset ) { // First batch: fetch and parse the live feed. $feed_data = $this->parse_feed( $rss_feed_url ); if ( is_wp_error( $feed_data ) ) { $this->add_import_log( $podcast_id, array( 'error' => $feed_data->get_error_message() ) ); $this->update_import_schedule( $podcast_id, $auto_update_episodes, $auto_update_frequency ); return; } // Cache episode list for continuation batches — avoids re-fetching the remote feed. // Episodes are stored in 50-item chunks to stay within MySQL max_allowed_packet. $this->set_chunked_transient( $feed_transient_key, $feed_data['episodes'], 30 * MINUTE_IN_SECONDS ); $all_episodes = $feed_data['episodes']; $podcast_feed = $feed_data['podcast']; } else { // Continuation batch: read episode list from transient — no HTTP request. $all_episodes = $this->get_chunked_transient( $feed_transient_key ); if ( false === $all_episodes ) { // Transient expired (edge case: batches took longer than 30 min). Re-fetch. $feed_data = $this->parse_feed( $rss_feed_url ); if ( is_wp_error( $feed_data ) ) { delete_post_meta( $podcast_id, 'import_cron_offset' ); $this->add_import_log( $podcast_id, array( 'error' => $feed_data->get_error_message() ) ); $this->update_import_schedule( $podcast_id, $auto_update_episodes, $auto_update_frequency ); return; } $this->set_chunked_transient( $feed_transient_key, $feed_data['episodes'], 30 * MINUTE_IN_SECONDS ); $all_episodes = $feed_data['episodes']; } } $this->set_import_performance_mode( true ); // Import podcast metadata on the first batch only. if ( 0 === $batch_offset ) { $podcast_data = $this->import_podcast( $podcast_id, $podcast_feed, array( 'rss_feed_url' => $rss_feed_url, 'update_existing_content' => $update_existing_content, 'author_id' => $author_id, 'set_featured_image' => $set_featured_image, 'auto_update_episodes' => $auto_update_episodes, 'auto_update_frequency' => $auto_update_frequency ) ); if ( is_wp_error( $podcast_data ) ) { $this->delete_chunked_transient( $feed_transient_key ); $this->add_import_log( $podcast_id, array( 'error' => $podcast_data->get_error_message() ) ); $this->update_import_schedule( $podcast_id, $auto_update_episodes, $auto_update_frequency ); $this->set_import_performance_mode( false ); return; } } // Process the current batch of episodes. $total_episodes = count( $all_episodes ); $batch_episodes = array_slice( $all_episodes, $batch_offset, $batch_size ); $episodes_data = $this->import_episodes( $podcast_id, $batch_episodes, array( 'update_existing_content' => $update_existing_content, 'author_id' => $author_id, 'set_featured_image' => $set_featured_image ) ); if ( is_wp_error( $episodes_data ) ) { $this->delete_chunked_transient( $feed_transient_key ); delete_post_meta( $podcast_id, 'import_cron_offset' ); $this->add_import_log( $podcast_id, array( 'error' => $episodes_data->get_error_message() ) ); $this->update_import_schedule( $podcast_id, $auto_update_episodes, $auto_update_frequency ); $this->set_import_performance_mode( false ); return; } // Advance by episodes actually processed rather than the nominal batch size, // so nothing is skipped if import_episodes ever stops a batch early. $processed = isset( $episodes_data['processed'] ) ? (int) $episodes_data['processed'] : $batch_size; $next_offset = $batch_offset + $processed; if ( $next_offset < $total_episodes ) { // More episodes remain — store offset and reschedule immediately for the next batch. // The log entry is skipped here; only the final batch writes to the import history. update_post_meta( $podcast_id, 'import_cron_offset', $next_offset ); update_post_meta( $podcast_id, 'import_next_schedule', gmdate( 'Y-m-d H:i:s' ) ); } else { // All episodes processed — clean up batch state and schedule the next normal run. // Note: $episodes_data reflects the final batch only, not the full import cycle. $this->delete_chunked_transient( $feed_transient_key ); delete_post_meta( $podcast_id, 'import_cron_offset' ); $this->add_import_log( $podcast_id, $episodes_data ); $this->update_import_schedule( $podcast_id, $auto_update_episodes, $auto_update_frequency ); $this->bust_feed_caches( $podcast_id ); } $this->set_import_performance_mode( false ); } /** * Import Podcast from RSS Feed. * * @since 1.0.0 */ public function ajax_callback_import_podcast() { // Security check check_ajax_referer( 'aiopod_ajax_nonce', 'security' ); if ( ! current_user_can( 'manage_aiopod_options' ) ) { wp_send_json_error( array( 'error' => __( 'Sorry, you don\'t have permission to perform this action.', 'aio-podcast-studio' ) ) ); } // Get import settings $appearance_settings = aiopod_get_option( 'aiopod_appearance_settings' ); $podcast_id = isset( $_POST['podcast_id'] ) ? (int) wp_unslash( $_POST['podcast_id'] ) : 0; $rss_feed_url = isset( $_POST['rss_feed_url'] ) ? aiopod_sanitize_url( wp_unslash( $_POST['rss_feed_url'] ) ) : ''; $update_existing_content = isset( $_POST['update_existing_content'] ) ? (int) wp_unslash( $_POST['update_existing_content'] ) : 0; $auto_update_episodes = isset( $_POST['auto_update_episodes'] ) ? (int) wp_unslash( $_POST['auto_update_episodes'] ) : 0; $auto_update_frequency = isset( $_POST['auto_update_frequency'] ) ? (int) wp_unslash( $_POST['auto_update_frequency'] ) : 0; $offset = isset( $_POST['offset'] ) ? (int) wp_unslash( $_POST['offset'] ) : 0; $limit = isset( $_POST['limit'] ) ? (int) wp_unslash( $_POST['limit'] ) : 20; $total = isset( $_POST['total'] ) ? (int) wp_unslash( $_POST['total'] ) : 0; $imported = isset( $_POST['imported'] ) ? (int) wp_unslash( $_POST['imported'] ) : 0; $updated = isset( $_POST['updated'] ) ? (int) wp_unslash( $_POST['updated'] ) : 0; $skipped = isset( $_POST['skipped'] ) ? (int) wp_unslash( $_POST['skipped'] ) : 0; $failed = isset( $_POST['failed'] ) ? (int) wp_unslash( $_POST['failed'] ) : 0; $author_id = $this->get_author_id( $podcast_id ); $is_update = $podcast_id > 0 ? true : false; $set_featured_image = 0; if ( ! empty( $appearance_settings['enable_featured_images'] ) && ! empty( $appearance_settings['download_external_images'] ) ) { // Set featured image $set_featured_image = isset( $_POST['set_featured_image'] ) ? (int) wp_unslash( $_POST['set_featured_image'] ) : 0; } // Validate RSS feed URL if ( empty( $rss_feed_url ) ) { wp_send_json_error( array( 'error' => __( 'Please enter an RSS feed URL', 'aio-podcast-studio' ) ) ); } // Validate podcast_id post type before fetching the feed. if ( $podcast_id > 0 ) { $post = get_post( $podcast_id ); if ( ! $post || 'aiopod_podcasts' !== $post->post_type ) { wp_send_json_error( array( 'error' => __( 'Invalid podcast ID.', 'aio-podcast-studio' ) ) ); } } // Set time limit if ( function_exists( 'set_time_limit' ) ) { set_time_limit( 1200 ); } // Parse the RSS feed — use a transient cache on subsequent batches so // the remote feed is fetched only once per import run instead of once // per batch (e.g. 50 fetches for a 1,000-episode feed at batch size 20). $feed_transient_key = 'import_feed_' . md5( $rss_feed_url ); if ( 0 === $offset ) { $feed_data = $this->parse_feed( $rss_feed_url ); if ( ! is_wp_error( $feed_data ) ) { // Store podcast metadata (small) and episodes (chunked) separately to // avoid max_allowed_packet errors when the serialised episode list is large. set_transient( 'aiopod_' . $feed_transient_key . '_podcast', $feed_data['podcast'], 30 * MINUTE_IN_SECONDS ); $this->set_chunked_transient( $feed_transient_key, $feed_data['episodes'], 30 * MINUTE_IN_SECONDS ); } } else { $podcast_cached = get_transient( 'aiopod_' . $feed_transient_key . '_podcast' ); $episodes_cached = $this->get_chunked_transient( $feed_transient_key ); $feed_data = ( false !== $podcast_cached && false !== $episodes_cached ) ? array( 'podcast' => $podcast_cached, 'episodes' => $episodes_cached ) : false; if ( false === $feed_data ) { // Transient expired mid-import (edge case) — re-fetch and re-cache. $feed_data = $this->parse_feed( $rss_feed_url ); if ( ! is_wp_error( $feed_data ) ) { set_transient( 'aiopod_' . $feed_transient_key . '_podcast', $feed_data['podcast'], 30 * MINUTE_IN_SECONDS ); $this->set_chunked_transient( $feed_transient_key, $feed_data['episodes'], 30 * MINUTE_IN_SECONDS ); } } } if ( is_wp_error( $feed_data ) ) { delete_transient( 'aiopod_' . $feed_transient_key . '_podcast' ); $this->delete_chunked_transient( $feed_transient_key ); wp_send_json_error( array( 'error' => $feed_data->get_error_message() ) ); } // Import podcast $this->set_import_performance_mode( true ); if ( 0 === $offset ) { $podcast_data = $this->import_podcast( $podcast_id, $feed_data['podcast'], array( 'rss_feed_url' => $rss_feed_url, 'update_existing_content' => $update_existing_content, 'author_id' => $author_id, 'set_featured_image' => $set_featured_image, 'auto_update_episodes' => $auto_update_episodes, 'auto_update_frequency' => $auto_update_frequency ) ); if ( is_wp_error( $podcast_data ) ) { delete_transient( 'aiopod_' . $feed_transient_key . '_podcast' ); $this->delete_chunked_transient( $feed_transient_key ); $this->set_import_performance_mode( false ); wp_send_json_error( array( 'error' => $podcast_data->get_error_message() ) ); } if ( empty( $podcast_id ) ) { $podcast_id = $podcast_data; } } // Import episodes $episodes = ( $limit > 0 ) ? array_slice( $feed_data['episodes'], $offset, $limit ) : array_slice( $feed_data['episodes'], $offset ); $episodes_data = $this->import_episodes( $podcast_id, $episodes, array( 'update_existing_content' => $update_existing_content, 'author_id' => $author_id, 'set_featured_image' => $set_featured_image, // Keep each AJAX request within the host's execution limit. Filterable so // hosts with tighter (or looser) caps can tune it. 0 disables the budget. 'time_budget' => (int) apply_filters( 'aiopod_import_time_budget', 20 ) ) ); if ( is_wp_error( $episodes_data ) ) { delete_transient( 'aiopod_' . $feed_transient_key . '_podcast' ); $this->delete_chunked_transient( $feed_transient_key ); $this->set_import_performance_mode( false ); wp_send_json_error( array( 'error' => $episodes_data->get_error_message() ) ); } // Prepare response // Advance by the number of episodes actually processed. This may be fewer // than the requested $limit when the time budget stops the batch early, so // the next request resumes exactly where this one left off. $new_offset = min( $offset + (int) $episodes_data['processed'], count( $feed_data['episodes'] ) ); $response = array( 'podcast_id' => $podcast_id, 'step' => 'process', 'offset' => $new_offset, 'total' => count( $feed_data['episodes'] ), 'imported' => $imported + $episodes_data['imported'], 'updated' => $updated + $episodes_data['updated'], 'skipped' => $skipped + $episodes_data['skipped'], 'failed' => $failed + $episodes_data['failed'] ); // Interim response for pagination if ( count( $feed_data['episodes'] ) > $new_offset ) { $response['message'] = sprintf( __( 'Importing Episodes: Processed %1$d of %2$d episodes. Please do not close or refresh this page.', 'aio-podcast-studio' ), $new_offset, count( $feed_data['episodes'] ) ); $this->add_import_log( $podcast_id, $response ); $this->set_import_performance_mode( false ); wp_send_json_success( $response ); } // Final response $response['step'] = 'complete'; $response['redirect'] = get_edit_post_link( $podcast_id, 'raw' ); if ( $is_update ) { $response['message'] = sprintf( __( 'Congrats! Podcast updated successfully. Total episodes: %d (Imported: %d, Updated: %d, Duplicate: %d, Failed: %d). Please wait while we refresh the page.', 'aio-podcast-studio' ), $response['total'], $response['imported'], $response['updated'], $response['skipped'], $response['failed'] ); } else { $response['message'] = sprintf( __( 'Congrats! Podcast imported successfully. Total episodes: %d (Imported: %d, Updated: %d, Duplicate: %d, Failed: %d). Please wait while we refresh the page.', 'aio-podcast-studio' ), $response['total'], $response['imported'], $response['updated'], $response['skipped'], $response['failed'] ); } delete_transient( 'aiopod_' . $feed_transient_key . '_podcast' ); $this->delete_chunked_transient( $feed_transient_key ); $this->add_import_log( $podcast_id, $response ); $this->update_import_schedule( $podcast_id, $auto_update_episodes, $auto_update_frequency ); $this->bust_feed_caches( $podcast_id ); $this->set_import_performance_mode( false ); wp_send_json_success( $response ); } /** * Parses a podcast RSS feed into a structured array. * * @since 1.0.0 * @access private * @param string $feed_url The URL of the podcast RSS feed to parse. * @return array|WP_Error Associative array of podcast details or WP_Error on failure. */ private function parse_feed( $feed_url ) { if ( empty( $feed_url ) || ! filter_var( $feed_url, FILTER_VALIDATE_URL ) ) { return new WP_Error( 'invalid_url', __( 'The RSS feed URL format seems invalid. Please check and try again.', 'aio-podcast-studio' ) ); } if ( ! function_exists( 'simplexml_load_string' ) ) { return new WP_Error( 'missing_simplexml', __( 'The server is missing required functionality (SimpleXML). Please contact your host.', 'aio-podcast-studio' ) ); } $response = wp_remote_get( $feed_url, array( 'timeout' => 30 ) ); if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) !== 200 ) { return new WP_Error( 'http_error', __( 'We couldn\'t fetch the RSS feed. Please make sure the URL is correct and accessible.', 'aio-podcast-studio' ) ); } $body = wp_remote_retrieve_body( $response ); libxml_use_internal_errors( true ); $rss = simplexml_load_string( $body, 'SimpleXMLElement', LIBXML_NOCDATA ); libxml_clear_errors(); libxml_use_internal_errors( false ); if ( ! $rss || ! isset( $rss->channel ) ) { return new WP_Error( 'rss_parse_error', __( 'The RSS feed could not be parsed. Please verify its structure or try a different feed.', 'aio-podcast-studio' ) ); } $channel = $rss->channel; $itunes_ns = 'http://www.itunes.com/dtds/podcast-1.0.dtd'; $itunes = $channel->children( $itunes_ns ); $content_ns = 'http://purl.org/rss/1.0/modules/content/'; $content = $channel->children( $content_ns ); $podcast = array( 'title' => '', 'description' => '' ); $episodes = array(); // Podcast: Title if ( isset( $channel->title ) && trim( (string) $channel->title ) !== '' ) { $podcast['title'] = trim( (string) $channel->title ); } elseif ( isset( $itunes->title ) && trim( (string) $itunes->title ) !== '' ) { $podcast['title'] = trim( (string) $itunes->title ); } // Podcast: Description // // Every text element in the feed is read into the description, because none of them is // reliably short. `itunes:summary` is a full description by specification, and although // `itunes:subtitle` is meant to be a single line, nothing enforces that and some feeds // simply repeat the description in it. An excerpt is therefore never imported: it is // generated from the description instead, so the excerpt length setting is always honoured // and the excerpt field stays free for wording the user has written themselves. if ( isset( $content->encoded ) && trim( (string) $content->encoded ) !== '' ) { $podcast['description'] = trim( (string) $content->encoded ); } elseif ( isset( $channel->description ) && trim( (string) $channel->description ) !== '' ) { $podcast['description'] = trim( (string) $channel->description ); } elseif ( isset( $itunes->summary ) && trim( (string) $itunes->summary ) !== '' ) { $podcast['description'] = trim( (string) $itunes->summary ); } elseif ( isset( $itunes->subtitle ) && trim( (string) $itunes->subtitle ) !== '' ) { $podcast['description'] = trim( (string) $itunes->subtitle ); } // Podcast: Image if ( isset( $channel->image->url ) && trim( (string) $channel->image->url ) !== '' ) { $podcast['image'] = aiopod_sanitize_url( trim( (string) $channel->image->url ) ); } elseif ( isset( $itunes->image ) && $itunes->image->attributes() && isset( $itunes->image->attributes()->href ) ) { $podcast['image'] = aiopod_sanitize_url( trim( (string) $itunes->image->attributes()->href ) ); } // Podcast: Language if ( isset( $channel->language ) && trim( (string) $channel->language ) !== '' ) { $podcast['podcast_language'] = sanitize_text_field( trim( (string) $channel->language ) ); } // Podcast: Categories $categories = array(); if ( isset( $itunes->category ) ) { foreach ( $itunes->category as $main_cat ) { $main_attr = $main_cat->attributes(); $main_text = isset( $main_attr['text'] ) ? trim( (string) $main_attr['text'] ) : ''; if ( isset( $main_cat->category ) ) { foreach ( $main_cat->category as $sub_cat ) { $sub_attr = $sub_cat->attributes(); $sub_text = isset( $sub_attr['text'] ) ? trim( (string) $sub_attr['text'] ) : ''; if ( $main_text ) { $categories[] = $main_text . ( $sub_text ? '|' . $sub_text : '' ); } } } elseif ( $main_text ) { $categories[] = $main_text; } } } if ( ! empty( $categories ) ) { $podcast['categories'] = array_map( 'sanitize_text_field', $categories ); } // Podcast: Explicit if ( isset( $itunes->explicit ) && trim( (string) $itunes->explicit ) !== '' ) { $podcast['is_explicit'] = $this->parse_bool( $itunes->explicit ); } // Podcast: Author if ( isset( $itunes->author ) && trim( (string) $itunes->author ) !== '' ) { $podcast['author_name'] = sanitize_text_field( trim( (string) $itunes->author ) ); } // Podcast: Author Email / Owner Name fallback if ( isset( $itunes->owner ) ) { $owner = $itunes->owner->children( $itunes_ns ); if ( isset( $owner->email ) && trim( (string) $owner->email ) !== '' ) { $podcast['author_email'] = sanitize_text_field( trim( (string) $owner->email ) ); } if ( empty( $podcast['author_name'] ) && isset( $owner->name ) && trim( (string) $owner->name ) !== '' ) { $podcast['author_name'] = sanitize_text_field( trim( (string) $owner->name ) ); } } // Podcast: Link if ( isset( $channel->link ) && trim( (string) $channel->link ) !== '' ) { $podcast['website_url'] = aiopod_sanitize_url( trim( (string) $channel->link ) ); } // Podcast: Type if ( isset( $itunes->type ) && trim( (string) $itunes->type ) !== '' ) { $podcast['podcast_type'] = sanitize_text_field( strtolower( trim( (string) $itunes->type ) ) ); } // Podcast: Copyright if ( isset( $channel->copyright ) && trim( (string) $channel->copyright ) !== '' ) { $podcast['copyright_text'] = sanitize_text_field( trim( (string) $channel->copyright ) ); } // Podcast: New Feed URL if ( isset( $itunes->{'new-feed-url'} ) && trim( (string) $itunes->{'new-feed-url'} ) !== '' ) { $podcast['new_feed_url'] = aiopod_sanitize_url( trim( (string) $itunes->{'new-feed-url'} ) ); } // Podcast: Block if ( isset( $itunes->block ) && trim( (string) $itunes->block ) !== '' ) { $podcast['is_blocked'] = $this->parse_bool( $itunes->block ); } // Podcast: Complete if ( isset( $itunes->complete ) && trim( (string) $itunes->complete ) !== '' ) { $podcast['is_completed'] = $this->parse_bool( $itunes->complete ); } // Episodes $allowed_episode_types = aiopod_get_episode_types(); $allowed_episode_types = array_keys( $allowed_episode_types ); if ( isset( $channel->item ) && ! empty( $channel->item ) ) { foreach ( $channel->item as $item ) { $itunes = $item->children( $itunes_ns ); $content = $item->children( $content_ns ); $episode = array( 'title' => '', 'description' => '' ); // Episode: Title if ( isset( $item->title ) && trim( (string) $item->title ) !== '' ) { $episode['title'] = trim( (string) $item->title ); } elseif ( isset( $itunes->title ) && trim( (string) $itunes->title ) !== '' ) { $episode['title'] = trim( (string) $itunes->title ); } // Episode: Description // // See the note on the podcast description above: no feed element is reliably short, // so every one of them is read into the description and the excerpt is generated // from it rather than imported. if ( isset( $content->encoded ) && trim( (string) $content->encoded ) !== '' ) { $episode['description'] = trim( (string) $content->encoded ); } elseif ( isset( $item->description ) && trim( (string) $item->description ) !== '' ) { $episode['description'] = trim( (string) $item->description ); } elseif ( isset( $itunes->summary ) && trim( (string) $itunes->summary ) !== '' ) { $episode['description'] = trim( (string) $itunes->summary ); } elseif ( isset( $itunes->subtitle ) && trim( (string) $itunes->subtitle ) !== '' ) { $episode['description'] = trim( (string) $itunes->subtitle ); } // Episode: Tags if ( isset( $itunes->keywords ) && trim( (string) $itunes->keywords ) !== '' ) { $keywords = trim( (string) $itunes->keywords ); $keywords = array_map( 'trim', explode( ',', $keywords ) ); $keywords = array_map( 'sanitize_text_field', $keywords ); $keywords = array_filter( $keywords ); $episode['tags'] = $keywords; } // Episode: Enclosure if ( isset( $item->enclosure ) ) { if ( isset( $item->enclosure['url'] ) && trim( (string) $item->enclosure['url'] ) !== '' ) { $episode['episode_file'] = aiopod_sanitize_url( trim( (string) $item->enclosure['url'] ) ); } if ( isset( $item->enclosure['type'] ) && trim( (string) $item->enclosure['type'] ) !== '' ) { $episode['episode_file_type'] = sanitize_text_field( trim( (string) $item->enclosure['type'] ) ); } if ( isset( $item->enclosure['length'] ) && trim( (string) $item->enclosure['length'] ) !== '' ) { $episode['episode_file_size'] = aiopod_sanitize_int( trim( (string) $item->enclosure['length'] ) ); } } // Episode: Duration if ( isset( $itunes->duration ) && trim( (string) $itunes->duration ) !== '' ) { $episode['episode_duration'] = aiopod_convert_time_to_seconds( trim( (string) $itunes->duration ) ); } // Episode: GUID if ( isset( $item->guid ) && trim( (string) $item->guid ) !== '' ) { $episode['guid'] = trim( (string) $item->guid ); } // Episode: Date if ( isset( $item->pubDate ) && trim( (string) $item->pubDate ) !== '' ) { $date_obj = date_create( trim( (string) $item->pubDate ) ); $episode['post_date'] = $date_obj ? date_format( $date_obj, 'Y-m-d H:i:s' ) : ''; } // Episode: Link if ( isset( $item->link ) && trim( (string) $item->link ) !== '' ) { $episode['link'] = trim( (string) $item->link ); } // Episode: Image if ( isset( $itunes->image ) && $itunes->image->attributes() && isset( $itunes->image->attributes()->href ) ) { $episode['image'] = aiopod_sanitize_url( trim( (string) $itunes->image->attributes()->href ) ); } // Episode: Explicit if ( isset( $itunes->explicit ) && trim( (string) $itunes->explicit ) !== '' ) { $episode['is_explicit'] = $this->parse_bool( $itunes->explicit ); } // Episode Number if ( isset( $itunes->episode ) && trim( (string) $itunes->episode ) !== '' ) { $episode['episode_number'] = aiopod_sanitize_int( trim( (string) $itunes->episode ) ); } // Episode: Season Number if ( isset( $itunes->season ) && trim( (string) $itunes->season ) !== '' ) { $episode['season_number'] = aiopod_sanitize_int( trim( (string) $itunes->season ) ); } // Episode: Type if ( isset( $itunes->episodeType ) && trim( (string) $itunes->episodeType ) !== '' ) { $type = sanitize_key( strtolower( trim( (string) $itunes->episodeType ) ) ); $episode['episode_type'] = in_array( $type, $allowed_episode_types, true ) ? $type : 'full'; } else { // Default if not provided $episode['episode_type'] = 'full'; } // Episode: Author if ( isset( $itunes->author ) && trim( (string) $itunes->author ) !== '' ) { $episode['author_name'] = sanitize_text_field( trim( (string) $itunes->author ) ); } // Episode: Block if ( isset( $itunes->block ) && trim( (string) $itunes->block ) !== '' ) { $episode['is_blocked'] = $this->parse_bool( $itunes->block ); } // Episode: Transcripts (Podcasting 2.0), kept as links: nothing is fetched during an import $transcripts = $this->parse_transcripts( $item, isset( $podcast['podcast_language'] ) ? $podcast['podcast_language'] : '' ); if ( ! empty( $transcripts ) ) { $episode['transcripts'] = $transcripts; $episode['enable_transcription'] = 1; } /** * A single episode's parsed feed data, before it is imported. * * Every element the feed offers as a description is read into `description`, while * `excerpt` is deliberately left unset so that the excerpt is generated from the * description and the excerpt length setting is honoured. No feed element is * reliably short: `itunes:summary` is a full description by specification, and * although `itunes:subtitle` is meant to be a single line, nothing enforces it and * some feeds repeat the whole description there. * * Where a particular feed is known to behave, this filter can map it across: * * add_filter( 'aiopod_import_parsed_episode', function( $episode, $item ) { * $itunes = $item->children( 'http://www.itunes.com/dtds/podcast-1.0.dtd' ); * * if ( isset( $itunes->subtitle ) ) { * $episode['excerpt'] = trim( (string) $itunes->subtitle ); * } * * return $episode; * }, 10, 2 ); * * @since 1.0.4 * @param array $episode Parsed episode data. * @param SimpleXMLElement $item The feed's element. * @param string $feed_url URL of the feed being imported. */ $episode = apply_filters( 'aiopod_import_parsed_episode', $episode, $item, $feed_url ); $episodes[] = $episode; } } /** * The podcast's parsed feed data, before it is imported. * * See `aiopod_import_parsed_episode` for why `excerpt` is left unset, and for an example * of mapping a feed element onto it. * * @since 1.0.4 * @param array $podcast Parsed podcast data. * @param SimpleXMLElement $channel The feed's element. * @param string $feed_url URL of the feed being imported. */ $podcast = apply_filters( 'aiopod_import_parsed_podcast', $podcast, $channel, $feed_url ); // Return parsed data $response = array( 'podcast' => $podcast, 'episodes' => $episodes ); return $response; } /** * Creates or updates a podcast from the given podcast data. * * @since 1.0.0 * @param int $podcast_id Podcast ID to update, or 0 to create a new podcast. * @param array $podcast Array of podcast data. * @param array $args Additional arguments for import settings. * @return int|WP_Error Podcast ID on success, or WP_Error on failure. */ public function import_podcast( $podcast_id, $podcast, $args = array() ) { // Validate required fields if ( empty( $podcast['title'] ) ) { return new WP_Error( 'create_podcast_failed', __( 'Podcast title not found in the feed. Please check the RSS source.', 'aio-podcast-studio' ) ); } // Check if podcast already exists by RSS feed URL if ( 0 === $podcast_id ) { $podcast_id = $this->is_podcast_exists( $args['rss_feed_url'] ); } // Determine if existing content can be updated $can_update_existing_content = apply_filters( 'aiopod_import_can_update_podcast', $args['update_existing_content'], $podcast_id, $podcast, $args ); if ( $podcast_id > 0 && ! $can_update_existing_content ) { // Update podcast meta $post_meta = array( 'set_featured_image' => $args['set_featured_image'], 'update_existing_content' => $args['update_existing_content'], 'auto_update_episodes' => $args['auto_update_episodes'], 'auto_update_frequency' => $args['auto_update_frequency'] ); $this->bulk_update_post_meta( $podcast_id, $post_meta ); return $podcast_id; } // Create or update podcast $is_new = false; $post_array = array( 'post_type' => 'aiopod_podcasts', 'post_title' => sanitize_text_field( wp_strip_all_tags( $podcast['title'] ) ), 'post_content' => ! empty( $podcast['description'] ) ? wp_kses_post( $podcast['description'] ) : '', // Never set while parsing, so this stays empty unless `aiopod_import_parsed_podcast` // supplied one. Cleared on update as well, so a refreshed description regenerates it 'post_excerpt' => ! empty( $podcast['excerpt'] ) ? sanitize_textarea_field( $podcast['excerpt'] ) : '', 'post_author' => $args['author_id'], 'post_status' => 'publish', 'ping_status' => 'closed', 'comment_status' => 'closed' ); if ( empty( $podcast_id ) ) { $podcast_id = wp_insert_post( $post_array ); if ( is_wp_error( $podcast_id ) || empty( $podcast_id ) ) { return new WP_Error( 'create_podcast_failed', is_wp_error( $podcast_id ) ? $podcast_id->get_error_message() : __( 'Unknown error creating podcast.', 'aio-podcast-studio' ) ); } $is_new = true; } else { $post_array['ID'] = $podcast_id; $updated = wp_update_post( $post_array, true ); if ( is_wp_error( $updated ) ) { return new WP_Error( 'update_podcast_failed', $updated->get_error_message() ); } } // Podcast terms if ( ! empty( $podcast['categories'] ) ) { $this->import_podcast_categories( $podcast_id, $podcast['categories'] ); } else { wp_set_object_terms( $podcast_id, array(), 'aiopod_categories' ); } // Podcast meta $defaults = array( 'image' => '', 'image_id' => 0, 'podcast_language' => '', 'podcast_type' => '', 'is_explicit' => 0, 'is_completed' => 0, 'author_name' => '%%author_name%%', 'author_email' => '%%author_email%%', 'website_url' => '%%podcast_url%%', 'copyright_text' => '', 'rss_feed_url' => $args['rss_feed_url'], 'new_feed_url' => '', 'is_blocked' => 0, 'total_downloads' => 0, 'total_plays' => 0, 'total_downloads_plays' => 0 ); $post_meta = array_intersect_key( $podcast, $defaults ); $post_meta['set_featured_image'] = $args['set_featured_image']; $post_meta['update_existing_content'] = $args['update_existing_content']; $post_meta['auto_update_episodes'] = $args['auto_update_episodes']; $post_meta['auto_update_frequency'] = $args['auto_update_frequency']; // Podcast image if ( ! empty( $args['set_featured_image'] ) && ! empty( $podcast['image'] ) ) { $image_id = aiopod_create_attachment_from_external_image_url( $podcast['image'], $podcast_id ); if ( ! empty( $image_id ) ) { $post_meta['image_id'] = $image_id; set_post_thumbnail( $podcast_id, $image_id ); } } // Update podcast meta if ( $is_new ) { $this->bulk_update_post_meta( $podcast_id, array_merge( $defaults, $post_meta ) ); } else { $this->bulk_update_post_meta( $podcast_id, $post_meta ); } // Clear podcast cache clean_post_cache( $podcast_id ); return $podcast_id; } /** * Creates or updates podcast episodes from the given episodes data. * * @since 1.0.0 * @param int $podcast_id Podcast ID to which episodes belong. * @param array $episodes Array of episode data. * @param array $args Additional arguments for import settings. * @return array|WP_Error Import response data on success, or WP_Error on failure. */ public function import_episodes( $podcast_id, $episodes, $args = array() ) { $response = array( 'imported' => 0, 'updated' => 0, 'skipped' => 0, 'failed' => 0, 'processed' => 0 ); // Bound the batch by wall-clock time so a single request never exceeds the // host's max execution limit while downloading external featured images. // Passed in by the caller; 0 (default) disables the time budget so the // full slice is always processed (e.g. background cron imports). $time_budget = isset( $args['time_budget'] ) ? (int) $args['time_budget'] : 0; $start_time = microtime( true ); // Import episodes foreach ( $episodes as $episode ) { // Stop before starting a new episode once the time budget is spent, but // always process at least one so the import offset keeps advancing. if ( $time_budget > 0 && $response['processed'] > 0 && ( microtime( true ) - $start_time ) >= $time_budget ) { break; } ++$response['processed']; if ( empty( $episode['title'] ) || empty( $episode['episode_file'] ) ) { ++$response['failed']; continue; } // Determine if existing content can be updated $episode_id = $this->is_episode_exists( $podcast_id, $episode ); $can_update_existing_content = apply_filters( 'aiopod_import_can_update_episode', $args['update_existing_content'], $podcast_id, $episode, $args ); if ( $episode_id > 0 && ! $can_update_existing_content ) { // Additive only: an episode with no transcript gets the feed's, and nothing else changes if ( ! empty( $episode['transcripts'] ) && array() === aiopod_get_transcripts( $episode_id ) ) { $this->save_transcripts( $episode_id, $episode['transcripts'] ); update_post_meta( $episode_id, 'enable_transcription', 1 ); clean_post_cache( $episode_id ); } ++$response['skipped']; continue; } // Create or update episode $is_new = false; $post_array = array( 'post_type' => 'aiopod_episodes', 'post_title' => sanitize_text_field( wp_strip_all_tags( $episode['title'] ) ), 'post_content' => ! empty( $episode['description'] ) ? wp_kses_post( $episode['description'] ) : '', // Never set while parsing, so this stays empty unless `aiopod_import_parsed_episode` // supplied one. Cleared on update as well, so a refreshed description regenerates it 'post_excerpt' => ! empty( $episode['excerpt'] ) ? sanitize_textarea_field( $episode['excerpt'] ) : '', 'post_author' => $args['author_id'], 'post_status' => 'publish', 'post_parent' => $podcast_id, 'ping_status' => 'closed', 'comment_status' => 'closed' ); if ( ! empty( $episode['post_date'] ) ) { $post_array['post_date'] = $episode['post_date']; } if ( empty( $episode_id ) ) { $episode_id = wp_insert_post( $post_array ); if ( is_wp_error( $episode_id ) || empty( $episode_id ) ) { ++$response['failed']; continue; } ++$response['imported']; $is_new = true; } else { $post_array['ID'] = $episode_id; $updated = wp_update_post( $post_array, true ); if ( is_wp_error( $updated ) ) { ++$response['failed']; continue; } ++$response['updated']; } // Episode terms if ( ! empty( $episode['tags'] ) ) { wp_set_object_terms( $episode_id, $episode['tags'], 'aiopod_tags', false ); } else { wp_set_object_terms( $episode_id, array(), 'aiopod_tags' ); } // Episode meta $defaults = array( 'guid' => '', 'episode_file' => '', 'episode_file_id' => 0, 'episode_file_type' => '', 'episode_file_size' => '', 'episode_duration' => '', 'is_video' => 0, 'image' => '', 'image_id' => 0, 'set_featured_image' => $args['set_featured_image'], 'episode_type' => 'full', 'season_number' => '', 'episode_number' => '', 'is_explicit' => 0, 'enable_transcription' => 0, 'transcription' => '', 'transcripts' => array(), 'enable_chapters' => 0, 'chapters' => array(), 'description' => '', 'author_name' => '', 'is_blocked' => 0, 'total_downloads' => 0, 'total_plays' => 0, 'total_downloads_plays' => 0 ); $post_meta = array_intersect_key( $episode, $defaults ); // Transcripts are one meta row each, so they are written apart from the single-value meta $transcripts = isset( $post_meta['transcripts'] ) ? $post_meta['transcripts'] : array(); unset( $post_meta['transcripts'], $defaults['transcripts'] ); // Is Video? if ( ! empty( $post_meta['episode_file'] ) || ! empty( $post_meta['episode_file_type'] ) ) { $is_video = aiopod_is_video( $post_meta['episode_file_type'], $post_meta['episode_file'] ); $post_meta['is_video'] = $is_video ? 1 : 0; } // Episode image if ( ! empty( $args['set_featured_image'] ) && ! empty( $post_meta['image'] ) ) { $image_id = aiopod_create_attachment_from_external_image_url( $post_meta['image'], $episode_id ); if ( ! empty( $image_id ) ) { $post_meta['image_id'] = $image_id; set_post_thumbnail( $episode_id, $image_id ); } } // Update episode meta if ( $is_new ) { $this->bulk_update_post_meta( $episode_id, array_merge( $defaults, $post_meta ) ); } else { $this->bulk_update_post_meta( $episode_id, $post_meta ); } // The feed's transcripts replace the episode's; a feed that lists none leaves them alone if ( ! empty( $transcripts ) ) { $this->save_transcripts( $episode_id, $transcripts ); } // Clear episode cache clean_post_cache( $episode_id ); } return $response; } /** * Toggle performance tuning during podcast import. * * @since 1.0.0 * @access private * @param bool $enable True to start tuning, false to restore. */ private function set_import_performance_mode( $enable ) { static $original_actions = null; static $is_enabled = false; $actions = array( 'transition_post_status', 'save_post', 'pre_post_update', 'add_attachment', 'edit_attachment', 'edit_post', 'post_updated', 'wp_insert_post', 'save_post_aiopod_podcasts', 'save_post_aiopod_episodes' ); if ( $enable ) { // Prevent double-enable if ( $is_enabled ) { return; } // Defer expensive operations wp_defer_term_counting( true ); wp_defer_comment_counting( true ); // Store and remove hooks $original_actions = array(); foreach ( $actions as $action ) { if ( isset( $GLOBALS['wp_filter'][ $action ] ) ) { $original_actions[ $action ] = $GLOBALS['wp_filter'][ $action ]; remove_all_actions( $action ); } } $is_enabled = true; } else { // Prevent double-restore if ( ! $is_enabled ) { return; } // Restore deferred operations wp_defer_term_counting( false ); wp_defer_comment_counting( false ); // Restore hooks if ( is_array( $original_actions ) ) { foreach ( $original_actions as $action => $hooks ) { $GLOBALS['wp_filter'][ $action ] = $hooks; } } $original_actions = null; $is_enabled = false; } } /** * Check if the podcast already exists based on the RSS feed URL. * * @since 1.0.0 * @access private * @param string $rss_feed_url The RSS feed URL to check. * @return int|false Post ID if podcast exists, false otherwise. */ private function is_podcast_exists( $rss_feed_url ) { if ( empty( $rss_feed_url ) ) { return false; } $args = array( 'post_type' => 'aiopod_podcasts', 'post_status' => 'any', 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => true, 'update_post_term_cache' => false, 'update_post_meta_cache' => false, 'meta_query' => array( array( 'key' => 'rss_feed_url', 'value' => aiopod_sanitize_url( $rss_feed_url ), 'compare' => '=' ) ) ); $aiopod_query = new WP_Query( $args ); return ! empty( $aiopod_query->posts ) ? (int) $aiopod_query->posts[0] : false; } /** * Check if the episode already exists for the given podcast. * * @since 1.0.0 * @access private * @param int $podcast_id Podcast post ID. * @param array $episode Array of episode data. * @return int|false Episode post ID if exists, false otherwise. */ private function is_episode_exists( $podcast_id, $episode ) { if ( empty( $podcast_id ) || ( empty( $episode['guid'] ) && empty( $episode['episode_file'] ) ) ) { return false; } $args = array( 'post_type' => 'aiopod_episodes', 'post_status' => 'any', 'post_parent' => (int) $podcast_id, 'posts_per_page' => 1, 'fields' => 'ids', 'no_found_rows' => true, 'update_post_term_cache' => false, 'update_post_meta_cache' => false ); $meta_queries = array(); // Optional match on GUID if ( ! empty( $episode['guid'] ) ) { $meta_queries[] = array( 'key' => 'guid', 'value' => sanitize_text_field( $episode['guid'] ), 'compare' => '=' ); } // Optional match on episode_file if ( ! empty( $episode['episode_file'] ) ) { $meta_queries[] = array( 'key' => 'episode_file', 'value' => aiopod_sanitize_url( $episode['episode_file'] ), 'compare' => '=' ); } // Combine meta queries with OR relation if ( ! empty( $meta_queries ) ) { $args['meta_query'] = ( count( $meta_queries ) > 1 ) ? array_merge( array( 'relation' => 'OR' ), $meta_queries ) : $meta_queries; } $aiopod_query = new WP_Query( $args ); return ! empty( $aiopod_query->posts ) ? (int) $aiopod_query->posts[0] : false; } /** * Get the author ID for a podcast. * * @since 1.0.0 * @access private * @param int $podcast_id The podcast post ID. * @return int The resolved author user ID. */ private function get_author_id( $podcast_id ) { $author_id = 0; // Attempt to get author directly from post field. if ( ! empty( $podcast_id ) ) { $author_id = (int) get_post_field( 'post_author', $podcast_id ); } // Fallback to current user. if ( empty( $author_id ) ) { $author_id = get_current_user_id(); } // Fallback to admin email user or user ID 1. if ( empty( $author_id ) ) { $admin_user = get_user_by( 'email', get_option( 'admin_email' ) ); $author_id = $admin_user ? (int) $admin_user->ID : 1; } return $author_id; } /** * Imports and assigns podcast categories from an array of delimited strings. * * @since 1.0.0 * @access private * @param int $podcast_id Post ID of the podcast. * @param array $category_strings Array of delimited category strings. */ private function import_podcast_categories( $podcast_id, $category_strings ) { // Normalize input if ( empty( $category_strings ) || ! is_array( $category_strings ) ) { wp_set_object_terms( $podcast_id, array(), 'aiopod_categories' ); return; } $term_ids = array(); foreach ( $category_strings as $category_string ) { if ( ! is_string( $category_string ) ) { continue; } // Split "Parent|Child" $parts = array_map( 'trim', explode( '|', $category_string ) ); $parts = array_filter( $parts ); if ( empty( $parts ) ) { continue; } // Parent + Child if ( count( $parts ) >= 2 ) { $parent_name = $parts[0]; $child_name = $parts[1]; // Parent term $parent_term = term_exists( $parent_name, 'aiopod_categories' ); if ( ! $parent_term ) { $parent_term = wp_insert_term( $parent_name, 'aiopod_categories' ); } if ( is_wp_error( $parent_term ) || empty( $parent_term['term_id'] ) ) { continue; } $parent_id = (int) $parent_term['term_id']; // Child term (scoped to parent) $child_term = term_exists( $child_name, 'aiopod_categories', $parent_id ); if ( ! $child_term ) { $child_term = wp_insert_term( $child_name, 'aiopod_categories', array( 'parent' => $parent_id ) ); } if ( ! is_wp_error( $child_term ) && ! empty( $child_term['term_id'] ) ) { $term_ids[] = (int) $child_term['term_id']; } else { // Fallback to parent if child fails $term_ids[] = $parent_id; } } else { // Single category $term_name = $parts[0]; $term = term_exists( $term_name, 'aiopod_categories' ); if ( ! $term ) { $term = wp_insert_term( $term_name, 'aiopod_categories' ); } if ( ! is_wp_error( $term ) && ! empty( $term['term_id'] ) ) { $term_ids[] = (int) $term['term_id']; } } } // Assign all categories at once if ( ! empty( $term_ids ) ) { wp_set_object_terms( $podcast_id, array_unique( $term_ids ), 'aiopod_categories', false ); } else { wp_set_object_terms( $podcast_id, array(), 'aiopod_categories' ); } } /** * Bulk update post meta. * * @since 1.0.0 * @access private * @param int $post_id Post ID. * @param array $data Meta key => value pairs. * @return bool True on success, false on failure. */ private function bulk_update_post_meta( $post_id, $data = array() ) { global $wpdb; $post_id = absint( $post_id ); if ( empty( $post_id ) || ! is_array( $data ) || empty( $data ) ) { return false; } $meta_table = $wpdb->postmeta; $meta_keys = array(); $placeholders = array(); $values = array(); foreach ( $data as $key => $value ) { if ( ! is_string( $key ) || '' === $key ) { continue; } $meta_keys[] = $key; $placeholders[] = '(%d, %s, %s)'; $values[] = $post_id; $values[] = $key; $values[] = maybe_serialize( $value ); } if ( empty( $meta_keys ) ) { return false; } // Delete existing meta values for the given keys. $delete_placeholders = implode( ', ', array_fill( 0, count( $meta_keys ), '%s' ) ); $delete_sql = $wpdb->prepare( "DELETE FROM {$meta_table} WHERE post_id = %d AND meta_key IN ({$delete_placeholders})", array_merge( array( $post_id ), $meta_keys ) ); if ( false === $wpdb->query( $delete_sql ) ) { return false; } // Bulk insert new meta values. $insert_sql = $wpdb->prepare( "INSERT INTO {$meta_table} (post_id, meta_key, meta_value) VALUES " . implode( ', ', $placeholders ), $values ); if ( false === $wpdb->query( $insert_sql ) ) { return false; } return true; } /** * Adds an import log entry for a podcast post. * * @since 1.0.0 * @access private * @param int $podcast_id The post ID of the podcast. * @param array $log_data The import log data. */ private function add_import_log( $podcast_id, $log_data = array() ) { if ( empty( $podcast_id ) || ! is_array( $log_data ) || empty( $log_data ) ) { return false; } $allowed_properties = array( 'date', 'error', 'imported', 'updated', 'skipped', 'failed' ); foreach ( $log_data as $property => $value ) { if ( ! in_array( $property, $allowed_properties ) ) { unset( $log_data[ $property ] ); } } if ( empty( $log_data['date'] ) ) { $log_data['date'] = gmdate( 'Y-m-d H:i:s' ); } add_post_meta( $podcast_id, 'import_logs', $log_data ); } /** * Updates the next import schedule for a podcast. * * @since 1.0.0 * @access private * @param int $podcast_id The ID of the podcast post. * @param mixed $auto_update_episodes Whether to auto-update episodes (truthy/falsy). * @param mixed $auto_update_frequency Update frequency in seconds. */ private function update_import_schedule( $podcast_id, $auto_update_episodes, $auto_update_frequency ) { if ( empty( $podcast_id ) || empty( $auto_update_episodes ) || empty( $auto_update_frequency ) ) { return false; } $next_schedule = gmdate( 'Y-m-d H:i:s', time() + (int) $auto_update_frequency ); update_post_meta( $podcast_id, 'import_next_schedule', $next_schedule ); } /** * Stores a flat array across multiple transients (50 items each) to avoid * MySQL max_allowed_packet errors when serialising large episode lists. * Chunk keys follow the pattern "aiopod_{key_suffix}_{n}"; "aiopod_{key_suffix}_chunks" * stores the total chunk count so get_chunked_transient() can reassemble the array. * * @since 1.0.0 * @access private * @param string $key_suffix Unique identifier appended after the aiopod_ namespace prefix. * @param array $items Flat array of items to store. * @param int $expiration TTL in seconds. */ private function set_chunked_transient( $key_suffix, array $items, $expiration ) { $chunks = array_chunk( $items, 50 ); set_transient( 'aiopod_' . $key_suffix . '_chunks', count( $chunks ), $expiration ); foreach ( $chunks as $index => $chunk ) { set_transient( 'aiopod_' . $key_suffix . '_' . $index, $chunk, $expiration ); } } /** * Retrieves and reassembles an array previously stored with set_chunked_transient(). * * @since 1.0.0 * @access private * @param string $key_suffix Unique identifier appended after the aiopod_ namespace prefix. * @return array|bool Reassembled array, or false if any chunk is missing. */ private function get_chunked_transient( $key_suffix ) { $count = get_transient( 'aiopod_' . $key_suffix . '_chunks' ); if ( false === $count ) { return false; } $items = array(); for ( $i = 0; $i < (int) $count; $i++ ) { $chunk = get_transient( 'aiopod_' . $key_suffix . '_' . $i ); if ( false === $chunk ) { return false; } $items = array_merge( $items, $chunk ); } return $items; } /** * Deletes all transient entries created by set_chunked_transient(). * * @since 1.0.0 * @access private * @param string $key_suffix Unique identifier appended after the aiopod_ namespace prefix. */ private function delete_chunked_transient( $key_suffix ) { $count = get_transient( 'aiopod_' . $key_suffix . '_chunks' ); if ( false !== $count ) { for ( $i = 0; $i < (int) $count; $i++ ) { delete_transient( 'aiopod_' . $key_suffix . '_' . $i ); } } delete_transient( 'aiopod_' . $key_suffix . '_chunks' ); } /** * Clear a podcast's cached feeds once an import run has finished. * * Saving a podcast or episode does this through `save_post`, which import performance mode * switches off, so without it an import's changes would reach the feed only when the cached * copy expired — up to 12 hours later. Both caches are cleared, exactly as a save clears them: * the public feed, and every member's feed through Member RSS's cache version. * * @since 1.0.5 * @access private * @param int $podcast_id Podcast ID. * @return void */ private function bust_feed_caches( $podcast_id ) { $podcast = get_post( $podcast_id ); if ( ! $podcast ) { return; } $feed = new AIOPOD_Public_Feed(); $feed->bust_feed_cache( $podcast->ID, $podcast ); $member_rss = new AIOPOD_Public_PMPro_Member_RSS(); $member_rss->bump_feed_cache_version( $podcast->ID, $podcast ); } /** * Replace an episode's transcripts with the given rows. * * One meta row per transcript, as the episode editor writes them. Nothing is fetched: the files * are read the first time something needs them. The legacy `transcription` key mirrors the first * text transcript, as it does when the episode is saved. * * @since 1.0.5 * @access private * @param int $episode_id Episode ID. * @param array $transcripts Transcript rows. * @return void */ private function save_transcripts( $episode_id, $transcripts ) { aiopod_clear_transcript_cache( $episode_id ); delete_post_meta( $episode_id, 'transcripts' ); $transcription = ''; foreach ( $transcripts as $transcript ) { $transcript = aiopod_normalize_transcript( $transcript ); if ( '' === $transcription && 'text' === $transcript['source'] ) { $transcription = $transcript['text']; } // Slashed, because add_post_meta() unslashes add_post_meta( $episode_id, 'transcripts', wp_slash( $transcript ) ); } update_post_meta( $episode_id, 'transcription', wp_slash( $transcription ) ); aiopod_clear_transcript_cache( $episode_id ); } /** * Read an item's tags into transcript rows. * * A feed may list the same transcript in several formats and several languages. One row is kept * per language — VTT, then SRT, JSON, HTML and plain text — because the other formats are built * from it on demand. A tag without a language is in the channel's language. Each row is a link to * the source file; its type is kept as the feed gave it. * * @since 1.0.5 * @access private * @param SimpleXMLElement $item The feed's element. * @param string $channel_language The channel's , or ''. * @return array Transcript rows, in the order their languages first appear. */ private function parse_transcripts( $item, $channel_language ) { $namespaces = array( 'https://podcastindex.org/namespace/1.0', // The address of the spec itself, which older feeds declare and clients must also accept 'https://github.com/Podcastindex-org/podcast-namespace/blob/main/docs/1.0.md' ); $ranks = array( 'vtt' => 5, 'srt' => 4, 'json' => 3, 'html' => 2, 'txt' => 1 ); $channel_language = aiopod_normalize_language_code( $channel_language ); $best = array(); foreach ( $namespaces as $namespace ) { foreach ( $item->children( $namespace )->transcript as $tag ) { $attributes = $tag->attributes(); $url = isset( $attributes['url'] ) ? aiopod_sanitize_url( trim( (string) $attributes['url'] ) ) : ''; if ( '' === $url ) continue; $type = isset( $attributes['type'] ) ? strtolower( sanitize_text_field( (string) $attributes['type'] ) ) : ''; // A missing or unrecognised type: the file's extension may still say what it is if ( '' === aiopod_get_transcript_format( $type ) ) { $type = aiopod_get_transcript_type_from_url( $url ); } $format = aiopod_get_transcript_format( $type ); if ( ! isset( $ranks[ $format ] ) ) continue; $language = isset( $attributes['language'] ) ? aiopod_normalize_language_code( sanitize_text_field( (string) $attributes['language'] ) ) : ''; if ( '' !== $language && ! preg_match( '/^[a-z]{2,3}(?:-[a-z0-9]{2,8})*$/', $language ) ) { $language = ''; } $key = '' !== $language ? $language : $channel_language; if ( isset( $best[ $key ] ) && $best[ $key ]['rank'] >= $ranks[ $format ] ) continue; $best[ $key ] = array( 'rank' => $ranks[ $format ], 'row' => array( 'source' => 'file', 'url' => $url, 'text' => '', 'type' => $type, 'language' => $language ) ); } } return array_values( wp_list_pluck( $best, 'row' ) ); } /** * Parses boolean-like values for podcast tags. * * Accepts "yes", "true", "1" as true (1), else false (0). * * @since 1.0.0 * @access private * @param mixed $value Input value to parse. * @return int 1 if true-ish, 0 otherwise. */ private function parse_bool( $value ) { $val = strtolower( trim( (string) $value ) ); return in_array( $val, array( 'yes', 'true', '1' ), true ) ? 1 : 0; } }