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;
}
}