ID; $is_blocked_or_invalid = get_post_meta( $podcast_id, 'is_blocked', true ); } // Invalid podcast feed → proper 404 if ( $is_blocked_or_invalid ) { global $wp_query; $wp_query->set_404(); status_header( 404 ); nocache_headers(); return; } // Valid podcast feed status_header( 200 ); nocache_headers(); header( 'Content-Type: application/rss+xml; charset=UTF-8' ); // WordPress 6.8 added `no-store` to the headers just sent, which tells the client to keep // no copy of the response at all. A client keeping no copy has no entity tag to revalidate // with, so it never asks whether the feed has changed and every poll downloads the whole // feed again. Replaced here without it. `private` is kept, and is the directive that // matters for a member feed: the subscriber's own client may store its copy, a shared // cache in front of the site may not header( 'Cache-Control: no-cache, must-revalidate, max-age=0, private' ); // Our own output is the same for every visitor, so one key per podcast is enough. An // add-on serving a member-specific feed varies this, otherwise one member's feed // would be cached and served to everyone. Note that bust_feed_cache() only clears // the key below, so an add-on that varies it also owns clearing its own entries $transient_key = apply_filters( 'aiopod_feed_cache_key', 'aiopod_feed_' . $podcast_id, $podcast_id ); $cache_enabled = (bool) apply_filters( 'aiopod_feed_cache_enabled', true, $podcast_id ); $cached_output = $cache_enabled ? get_transient( $transient_key ) : false; if ( false !== $cached_output ) { $output = $this->decode_cached_feed( $cached_output ); } else { ob_start(); $this->render_feed( $podcast ); $output = ob_get_clean(); if ( $cache_enabled ) { // Compress before caching to reduce the stored size and avoid // max_allowed_packet errors on large feeds. Plain storage is used // if the zlib extension is unavailable. If the transient write // fails for any reason the feed is already buffered and will be // served normally — the next request will simply re-render. $to_cache = $output; if ( function_exists( 'gzcompress' ) ) { $compressed = gzcompress( $output ); if ( false !== $compressed ) { // Base64 is not decoration. Transients live in a UTF-8 text // column, gzip output is binary, and the database rejects the // write outright — "Could not perform query because it contains // invalid data" — which the error suppression below then hides. // Stored raw, nothing was ever cached and every request // re-rendered the whole feed. $to_cache = base64_encode( $compressed ); } } // Suppress DB error logging for this write — a failure (e.g. // max_allowed_packet) is non-fatal and would otherwise flood // debug.log. The feed is served from $output either way. global $wpdb; $wpdb->hide_errors(); set_transient( $transient_key, $to_cache, 12 * HOUR_IN_SECONDS ); $wpdb->show_errors(); } } $this->handle_conditional_request( $output ); echo $output; // phpcs:ignore WordPress.Security.EscapeOutput exit; } /** * Turn a cached value back into feed XML. * * Entries are stored base64-encoded over gzip. Anything that does not decode that way is * returned as it is, which covers a feed stored plainly because the zlib extension was * missing, and any entry written by an earlier version. * * @since 1.0.3 * @access private * @param string $cached Value read from the transient. * @return string Feed XML. */ private function decode_cached_feed( $cached ) { if ( ! function_exists( 'gzuncompress' ) ) { return $cached; } // Strict, so feed XML stored plainly is rejected here rather than mangled $decoded = base64_decode( $cached, true ); if ( false !== $decoded ) { $decompressed = @gzuncompress( $decoded ); if ( false !== $decompressed ) { return $decompressed; } } return $cached; } /** * Answer a repeat request with 304 Not Modified when the feed has not changed. * * Podcast apps poll a feed far more often than it changes, and a member feed is polled once * per subscriber rather than once per show. A 304 costs a few hundred bytes where the reply * would otherwise be the whole feed. * * The entity tag is the checksum of the response body and nothing else. That is what makes it * correct here: a member feed's content depends on who is asking, so it can change without any * post being edited — a membership upgrading or lapsing is enough. Any validator built from * post timestamps would miss that and answer 304 to a member whose entitlement had just * changed, leaving them subscribed to a feed that never updates again. A `Last-Modified` * header is deliberately not sent for the same reason. * * @since 1.0.3 * @access private * @param string $output Feed body about to be sent. * @return void */ private function handle_conditional_request( $output ) { $etag = '"' . md5( $output ) . '"'; header( 'ETag: ' . $etag ); if ( empty( $_SERVER['HTTP_IF_NONE_MATCH'] ) ) { return; } $if_none_match = sanitize_text_field( wp_unslash( $_SERVER['HTTP_IF_NONE_MATCH'] ) ); // A client may offer several tags at once, and a proxy may have marked ours weak with a // `W/` prefix, so the tag is looked for within the header rather than compared to it if ( false === strpos( $if_none_match, $etag ) ) { return; } status_header( 304 ); // A 304 carries no body, so it must not describe one either header_remove( 'Content-Type' ); exit; } /** * Render the custom podcast RSS feed for a specific podcast post. * * @since 1.0.0 * @access private * @param WP_Post $podcast Podcast post object. * @param int */ private function render_feed( $podcast ) { $podcast_id = (int) $podcast->ID; $podcast_url = get_permalink( $podcast_id ); // Fetch episodes $args = array( 'post_type' => 'aiopod_episodes', 'post_status' => 'publish', 'post_parent' => $podcast_id, 'posts_per_page' => apply_filters( 'aiopod_feed_episodes_limit', 300 ), 'orderby' => 'date', 'order' => 'DESC' ); $episodes = get_posts( $args ); // Proceed with RSS XML output... echo '' . "\n"; echo '' . "\n"; echo "\t" . '' . "\n"; echo "\t\t" . '' . $this->esc_xml( $podcast->post_title ) . '' . "\n"; $feed_url = aiopod_get_rss_feed_url( $podcast ); echo "\t\t" . '' . "\n"; echo "\t\t" . mysql2date( 'r', $podcast->post_modified ) . "\n"; echo "\t\t" . $this->esc_xml( 'AiO Podcast Studio ' . AIOPOD_PLUGIN_VERSION ) . "\n"; if ( ! empty( $podcast->post_excerpt ) ) { echo "\t\t" . $this->esc_xml( $podcast->post_excerpt ) . "\n"; } elseif ( ! empty( $podcast->post_content ) ) { echo "\t\t" . $this->esc_xml( strip_tags( $podcast->post_content ) ) . "\n"; } $new_feed_url = get_post_meta( $podcast_id, 'new_feed_url', true ); if ( ! empty( $new_feed_url ) ) { echo "\t\t" . '' . esc_url( $new_feed_url ) . '' . "\n"; } $website_url = get_post_meta( $podcast_id, 'website_url', true ); if ( ! empty( $website_url ) ) { $website_url = str_replace( '%%podcast_url%%', $podcast_url, $website_url ); echo "\t\t" . '' . esc_url( $website_url ) . '' . "\n"; } else { echo "\t\t" . '' . esc_url( $podcast_url ) . '' . "\n"; } $language = aiopod_get_podcast_language( $podcast_id ); echo "\t\t" . '' . $this->esc_xml( $language ) . '' . "\n"; $copyright = get_post_meta( $podcast_id, 'copyright_text', true ); if ( ! empty( $copyright ) ) { echo "\t\t" . '' . $this->esc_xml( $copyright ) . '' . "\n"; } $author_name = get_post_meta( $podcast_id, 'author_name', true ); $author_name = str_replace( '%%author_name%%', get_the_author_meta( 'display_name', $podcast->post_author ), $author_name ); $author_email = get_post_meta( $podcast_id, 'author_email', true ); $author_email = str_replace( '%%author_email%%', get_the_author_meta( 'user_email', $podcast->post_author ), $author_email ); if ( ! empty( $author_name ) || ! empty( $author_email ) ) { echo "\t\t" . '' . "\n"; if ( ! empty( $author_name ) ) { echo "\t\t\t" . '' . $this->esc_xml( $author_name ) . '' . "\n"; } if ( ! empty( $author_email ) ) { echo "\t\t\t" . '' . $this->esc_xml( $author_email ) . '' . "\n"; } echo "\t\t" . '' . "\n"; } if ( ! empty( $author_name ) ) { echo "\t\t" . '' . $this->esc_xml( $author_name ) . '' . "\n"; } if ( ! empty( $podcast->post_content ) && ! empty( $podcast->post_excerpt ) ) { echo "\t\t" . 'post_excerpt ) . ']]>' . "\n"; echo "\t\t" . 'post_excerpt ) . ']]>' . "\n"; echo "\t\t" . 'post_content ) . ']]>' . "\n"; } else { if ( ! empty( $podcast->post_content ) ) { $post_excerpt = wp_strip_all_tags( $podcast->post_content ); echo "\t\t" . 'post_content ) . ']]>' . "\n"; echo "\t\t" . '' . "\n"; echo "\t\t" . 'post_content ) . ']]>' . "\n"; } if ( ! empty( $podcast->post_excerpt ) ) { echo "\t\t" . 'post_excerpt ) . ']]>' . "\n"; echo "\t\t" . 'post_excerpt ) . ']]>' . "\n"; } } $podcast_type = get_post_meta( $podcast_id, 'podcast_type', true ); if ( ! empty( $podcast_type ) ) { echo "\t\t" . '' . $this->esc_xml( $podcast_type ) . '' . "\n"; } $image = aiopod_get_image( array( 'post' => $podcast, 'size' => 'full' ) ); if ( ! empty( $image['src'] ) ) { echo "\t\t" . '' . "\n"; } $terms = wp_get_object_terms( $podcast_id, 'aiopod_categories' ); if ( ! is_wp_error( $terms ) && ! empty( $terms ) ) { $grouped_terms = array(); foreach ( $terms as $term ) { if ( ! empty( $term->parent ) ) { $parent = get_term( $term->parent, 'aiopod_categories' ); if ( is_wp_error( $parent ) || empty( $parent ) ) { // Fallback: treat as standalone $grouped_terms[0]['children'][] = $term; continue; } if ( ! isset( $grouped_terms[ $parent->term_id ] ) ) { $grouped_terms[ $parent->term_id ] = array( 'parent' => $parent, 'children' => array() ); } $grouped_terms[ $parent->term_id ]['children'][] = $term; } else { // Top-level category without children $grouped_terms[0]['children'][] = $term; } } // Output XML — Apple spec requires one parent block per subcategory. foreach ( $grouped_terms as $group ) { if ( ! empty( $group['parent'] ) && ! empty( $group['children'] ) ) { // One wrapper per child, as required by Apple. foreach ( $group['children'] as $child ) { echo "\t\t" . '' . "\n"; echo "\t\t\t" . '' . "\n"; echo "\t\t" . '' . "\n"; } } elseif ( ! empty( $group['children'] ) ) { // Standalone categories — skip any already output as a parent wrapper. foreach ( $group['children'] as $term ) { if ( ! isset( $grouped_terms[ $term->term_id ] ) ) { echo "\t\t" . '' . "\n"; } } } } } $is_explicit = (int) get_post_meta( $podcast_id, 'is_explicit', true ); echo "\t\t" . '' . ( $is_explicit ? 'true' : 'false' ) . '' . "\n"; $is_completed = (int) get_post_meta( $podcast_id, 'is_completed', true ); if ( ! empty( $is_completed ) ) { echo "\t\t" . 'Yes' . "\n"; } $is_blocked = (int) get_post_meta( $podcast_id, 'is_blocked', true ); if ( ! empty( $is_blocked ) ) { echo "\t\t" . 'Yes' . "\n"; } // Prime the episode tag term cache in a single query to avoid N+1 // queries inside the loop below. wp_get_object_terms() checks this // cache before hitting the database, so every per-episode call will // be served from memory after this point. if ( ! empty( $episodes ) ) { $episode_ids_for_cache = wp_list_pluck( $episodes, 'ID' ); wp_get_object_terms( $episode_ids_for_cache, 'aiopod_tags' ); } foreach ( $episodes as $episode ) { $is_blocked = get_post_meta( $episode->ID, 'is_blocked', true ); if ( $is_blocked ) continue; // Restricted episodes are left out of the public feed if ( ! $this->includes_content( $episode->ID, $podcast_id ) ) continue; echo "\t\t" . '' . "\n"; echo "\t\t\t" . esc_url( get_permalink( $episode->ID ) ) . "\n"; $episode_type = get_post_meta( $episode->ID, 'episode_type', true ); if ( ! empty( $episode_type ) ) { echo "\t\t\t" . '' . $this->esc_xml( $episode_type ) . '' . "\n"; } $episode_number = get_post_meta( $episode->ID, 'episode_number', true ); if ( ! empty( $episode_number ) ) { echo "\t\t\t" . '' . intval( $episode_number ) . '' . "\n"; } $season_number = get_post_meta( $episode->ID, 'season_number', true ); if ( ! empty( $season_number ) ) { echo "\t\t\t" . '' . intval( $season_number ) . '' . "\n"; } echo "\t\t\t" . '' . $this->esc_xml( $episode->post_title ) . '' . "\n"; if ( ! empty( $episode->post_content ) ) { echo "\t\t\t" . 'post_content ) . ']]>' . "\n"; } else { $description = get_post_meta( $episode->ID, 'description', true ); if ( ! empty( $description ) ) { echo "\t\t\t" . '' . $this->esc_xml( $description ) . '' . "\n"; } } $tags = wp_get_object_terms( $episode->ID, 'aiopod_tags', array( 'fields' => 'names' ) ); if ( ! is_wp_error( $tags ) && ! empty( $tags ) ) { $keywords = implode( ', ', $tags ); echo "\t\t\t" . '' . $this->esc_xml( $keywords ) . '' . "\n"; } $image = aiopod_get_image( array( 'post' => $episode, 'size' => 'full' ) ); if ( ! empty( $image['src'] ) ) { echo "\t\t\t" . '' . "\n"; } $episode_file = get_post_meta( $episode->ID, 'episode_file', true ); $episode_file_type = aiopod_get_episode_file_type( $episode->ID, $episode_file ); $episode_file_size = get_post_meta( $episode->ID, 'episode_file_size', true ); $allowed_mime_types = apply_filters( 'aiopod_feed_allowed_mime_types', array( 'audio/mpeg', 'audio/mp4', 'video/mp4', 'video/x-m4v', 'video/quicktime' ) ); // Enclosure requires at least URL + type if ( ! empty( $episode_file ) && ! empty( $episode_file_type ) && in_array( $episode_file_type, $allowed_mime_types, true ) ) { /** * Whether to use the tracking URL for the RSS feed enclosure. * * When true (default), the enclosure URL is routed through the * plugin's tracking endpoint so podcast-app downloads are counted. * Set to false to use the raw episode file URL instead — useful * when a third-party analytics proxy (e.g. Podtrac) is already * in the feed, or when statistics are not needed. * * add_filter( 'aiopod_rss_tracking_enabled', '__return_false' ); * * @since 1.0.0 * @param bool $enabled Whether tracking is enabled. Default true. * @param WP_Post $episode The episode post object. */ $rss_tracking_enabled = (bool) apply_filters( 'aiopod_rss_tracking_enabled', true, $episode ); $enclosure_url = $rss_tracking_enabled ? aiopod_get_episode_file_url( $episode ) : $episode_file; $attributes = array( 'url' => esc_url( $enclosure_url ), 'type' => esc_attr( $episode_file_type ) ); // Length is optional in the real world if ( ! empty( $episode_file_size ) && is_numeric( $episode_file_size ) ) { $attributes['length'] = esc_attr( (int) $episode_file_size ); } $enclosure = ' $value ) { $enclosure .= sprintf( ' %s="%s"', $key, $value ); } $enclosure .= ' />'; echo "\t\t\t" . $enclosure . "\n"; } // Podcasting 2.0 transcripts, one tag per format; nothing is fetched while the feed renders foreach ( aiopod_get_transcript_feed_entries( $episode ) as $transcript ) { $tag = 'ID, 'guid', true ); if ( empty( $guid ) ) { $guid = 'episode-' . $episode->ID; } echo sprintf( "\t\t\t%s\n", filter_var( $guid, FILTER_VALIDATE_URL ) ? '' : ' isPermaLink="false"', $this->esc_xml( $guid ) ); echo "\t\t\t" . '' . mysql2date( 'r', $episode->post_date ) . '' . "\n"; $duration = get_post_meta( $episode->ID, 'episode_duration', true ); if ( ! empty( $duration ) ) { echo "\t\t\t" . '' . $this->esc_xml( $duration ) . '' . "\n"; } $episode_author = get_post_meta( $episode->ID, 'author_name', true ); $episode_author = ! empty( $episode_author ) ? $episode_author : $author_name; if ( ! empty( $episode_author ) ) { echo "\t\t\t" . '' . $this->esc_xml( $episode_author ) . '' . "\n"; } $is_explicit = (int) get_post_meta( $episode->ID, 'is_explicit', true ); echo "\t\t\t" . '' . ( $is_explicit ? 'true' : 'false' ) . '' . "\n"; $is_blocked = (int) get_post_meta( $episode->ID, 'is_blocked', true ); if ( ! empty( $is_blocked ) ) { echo "\t\t\t" . 'Yes' . "\n"; } echo "\t\t" . '' . "\n"; } echo "\t" . '' . "\n"; echo ''; } /** * Whether a podcast or episode may appear in the feed currently being rendered. * * The feed is cached and served to every visitor alike, so the decision must not depend * on the current user. Restricted content is left out of the public feed for that * reason. An add-on serving an authenticated, member-specific feed filters this to * return what that member is entitled to, and varies `aiopod_feed_cache_key` to match. * * Applied to episodes only. A restricted podcast keeps its feed rather than losing it, * so that restricting one does not drop the podcast out of the directories it is * listed in. Its restricted episodes are left out of that feed the same as any other. * * @since 1.0.3 * @access private * @param int $post_id Podcast or episode ID being considered. * @param int $podcast_id ID of the podcast whose feed is being rendered. * @return bool True to include the content, false to leave it out. */ private function includes_content( $post_id, $podcast_id ) { return (bool) apply_filters( 'aiopod_feed_includes_content', ! aiopod_is_content_restricted( $post_id ), (int) $post_id, (int) $podcast_id ); } /** * Delete the RSS feed transient cache when a podcast or episode is saved. * * Hooked to save_post and before_delete_post so the cache is always * consistent after any content change. * * @since 1.0.0 * @param int $post_id Post ID. * @param WP_Post $post Post object. */ public function bust_feed_cache( $post_id, $post ) { if ( 'aiopod_podcasts' === $post->post_type ) { delete_transient( 'aiopod_feed_' . $post_id ); } elseif ( 'aiopod_episodes' === $post->post_type && $post->post_parent > 0 ) { delete_transient( 'aiopod_feed_' . $post->post_parent ); } } /** * Clear every cached feed on this site. * * `bust_feed_cache()` handles the ordinary case, where a podcast or episode is saved. It cannot * help when what changed was the *reason* content is restricted rather than the content itself: * switching restrictions on globally saves no post, so without this a feed cached while they * were off would go on listing episodes that had just become restricted, until it expired. * * A blunt instrument, and deliberately so — it runs only when a site-wide restriction setting is * saved, which is rare, and getting it wrong the other way leaves content exposed. * * Feeds cached in a persistent object cache cannot be enumerated and so are not cleared here; * those sites keep the twelve-hour expiry as their upper bound. * * @since 1.0.3 * @return void */ public function flush_feed_caches() { global $wpdb; $like = $wpdb->esc_like( '_transient_aiopod_feed_' ) . '%'; $names = $wpdb->get_col( $wpdb->prepare( "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s", $like ) ); foreach ( $names as $name ) { delete_transient( substr( $name, strlen( '_transient_' ) ) ); } } /** * Escape and encode text for safe use in podcast RSS feeds. * * @since 1.0.0 * @param string $content Raw content to be escaped. * @return string Escaped and encoded content. */ public function esc_xml( $content ) { $special_chars = array( '©' => '©', // Copyright sign '©' => '©', // Copyright sign '℗' => '℗', // Sound recording copyright '™' => '™', // Trademark '’' => ''', // Apostrophe (curly) '“' => '"', // Left quote '”' => '"', // Right quote ); // First, replace special characters $content = strtr( $content, $special_chars ); // Then escape for XML output return esc_xml( $content ); } }