View file File name : class-jetpack-podcast-helper.php Content :<?php /** * Helper to massage Podcast data to be used in the Podcast block. * * @package automattic/jetpack */ /** * Class Jetpack_Podcast_Helper */ class Jetpack_Podcast_Helper { /** * The RSS feed of the podcast. * * @var string */ protected $feed = null; /** * The number of seconds to cache the podcast feed data. * This value defaults to 1 hour specifically for podcast feeds. * The value can be overridden specifically for podcasts using the * `jetpack_podcast_feed_cache_timeout` filter. Note that the cache timeout value * for all RSS feeds can be modified using the `wp_feed_cache_transient_lifetime` * filter from WordPress core. * * @see https://developer.wordpress.org/reference/hooks/wp_feed_cache_transient_lifetime/ * @see WP_Feed_Cache_Transient * * @var int|null */ protected $cache_timeout = HOUR_IN_SECONDS; /** * Initialize class. * * @param string $feed The RSS feed of the podcast. */ public function __construct( $feed ) { $this->feed = esc_url_raw( $feed ); /** * Filter the number of seconds to cache a specific podcast URL for. The returned value will be ignored if it is null or not a valid integer. * Note that this timeout will only work if the site is using the default `WP_Feed_Cache_Transient` cache implementation for RSS feeds, * or their cache implementation relies on the `wp_feed_cache_transient_lifetime` filter. * * @since 11.3 * @see https://developer.wordpress.org/reference/hooks/wp_feed_cache_transient_lifetime/ * * @param int|null $cache_timeout The number of seconds to cache the podcast data. Default value is null, so we don't override any defaults from existing filters. * @param string $podcast_url The URL of the podcast feed. */ $podcast_cache_timeout = apply_filters( 'jetpack_podcast_feed_cache_timeout', $this->cache_timeout, $this->feed ); // Make sure we force new values for $this->cache_timeout to be integers. if ( is_numeric( $podcast_cache_timeout ) ) { $this->cache_timeout = (int) $podcast_cache_timeout; } } /** * Retrieves tracks quantity. * * @return int number of tracks */ public static function get_tracks_quantity() { /** * Allow requesting a specific number of tracks from SimplePie's `get_items` call. * The default number of tracks is ten. * * @since 10.4.0 * * @param int $number Number of tracks fetched. Default is 10. */ return (int) apply_filters( 'jetpack_podcast_helper_tracks_quantity', 10 ); } /** * Gets podcast data formatted to be used by the Podcast Player block in both server-side * block rendering and in API `WPCOM_REST_API_V2_Endpoint_Podcast_Player`. * * The result is cached for one hour. * * @param array $args { * Optional array of arguments. * @type string|int $guid The ID of a specific episode to return rather than a list. * } * * @return array|WP_Error The player data or a error object. */ public function get_player_data( $args = array() ) { $guids = isset( $args['guids'] ) && $args['guids'] ? $args['guids'] : array(); $episode_options = isset( $args['episode-options'] ) && $args['episode-options']; // Try loading data from the cache. $transient_key = 'jetpack_podcast_' . md5( $this->feed . implode( ',', $guids ) . "-$episode_options" ); $player_data = get_transient( $transient_key ); // Fetch data if we don't have any cached. if ( false === $player_data || ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) { // Load feed. $rss = $this->load_feed(); if ( is_wp_error( $rss ) ) { return $rss; } // Get a list of episodes by guid or all tracks in feed. if ( count( $guids ) ) { $tracks = array_map( array( $this, 'get_track_data' ), $guids ); $tracks = array_filter( $tracks, function ( $track ) { return ! is_wp_error( $track ); } ); } else { $tracks = $this->get_track_list(); } if ( empty( $tracks ) ) { return new WP_Error( 'no_tracks', __( 'Your Podcast couldn\'t be embedded as it doesn\'t contain any tracks. Please double check your URL.', 'jetpack' ) ); } // Get podcast meta. $title = $rss->get_title(); $title = $this->get_plain_text( $title ); $description = $rss->get_description(); $description = $this->get_plain_text( $description ); $cover = $rss->get_image_url(); $cover = ! empty( $cover ) ? esc_url( $cover ) : null; $link = $rss->get_link(); $link = ! empty( $link ) ? esc_url( $link ) : null; $player_data = array( 'title' => $title, 'description' => $description, 'link' => $link, 'cover' => $cover, 'tracks' => $tracks, ); if ( $episode_options ) { $player_data['options'] = array(); foreach ( $rss->get_items() as $episode ) { $enclosure = $this->get_audio_enclosure( $episode ); // If the episode doesn't have playable audio, then don't include it. if ( is_wp_error( $enclosure ) ) { continue; } $player_data['options'][] = array( 'label' => $this->get_plain_text( $episode->get_title() ), 'value' => $episode->get_id(), ); } } // Cache for 1 hour. set_transient( $transient_key, $player_data, HOUR_IN_SECONDS ); } return $player_data; } /** * Gets a specific track from the supplied feed URL. * * @param string $guid The GUID of the track. * @param boolean $force_refresh Clear the feed cache. * @return array|WP_Error The track object or an error object. */ public function get_track_data( $guid, $force_refresh = false ) { // Get the cache key. $transient_key = 'jetpack_podcast_' . md5( "$this->feed::$guid" ); // Clear the cache if force_refresh param is true. if ( true === $force_refresh ) { delete_transient( $transient_key ); } // Try loading track data from the cache. $track_data = get_transient( $transient_key ); // Fetch data if we don't have any cached. if ( false === $track_data || ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) { // Load feed. $rss = $this->load_feed( $force_refresh ); if ( is_wp_error( $rss ) ) { return $rss; } // Loop over all tracks to find the one. foreach ( $rss->get_items() as $track ) { if ( $guid === $track->get_id() ) { $track_data = $this->setup_tracks_callback( $track ); break; } } if ( false === $track_data ) { return new WP_Error( 'no_track', __( 'The track was not found.', 'jetpack' ) ); } // Cache for 1 hour. set_transient( $transient_key, $track_data, HOUR_IN_SECONDS ); } return $track_data; } /** * Gets a list of tracks for the supplied RSS feed. * * @return array|WP_Error The feed's tracks or a error object. */ public function get_track_list() { $rss = $this->load_feed(); if ( is_wp_error( $rss ) ) { return $rss; } $tracks_quantity = static::get_tracks_quantity(); /** * Allow requesting a specific number of tracks from SimplePie's `get_items` call. * The default number of tracks is ten. * Deprecated. Use jetpack_podcast_helper_tracks_quantity filter instead, which takes one less parameter. * * @since 9.5.0 * @deprecated 10.4.0 * * @param int $tracks_quantity Number of tracks fetched. Default is 10. * @param object $rss The SimplePie object built from core's `fetch_feed` call. */ $tracks_quantity = apply_filters_deprecated( 'jetpack_podcast_helper_list_quantity', array( $tracks_quantity, $rss ), '10.4.0', 'jetpack_podcast_helper_tracks_quantity' ); // Process the requested number of items from our feed. $track_list = array_map( array( __CLASS__, 'setup_tracks_callback' ), $rss->get_items( 0, $tracks_quantity ) ); // Filter out any tracks that are empty. // Reset the array indices. return array_values( array_filter( $track_list ) ); } /** * Formats string as pure plaintext, with no HTML tags or entities present. * This is ready to be used in React, innerText but needs to be escaped * using standard `esc_html` when generating markup on server. * * @param string $str Input string. * @return string Plain text string. */ protected function get_plain_text( $str ) { return $this->sanitize_and_decode_text( $str, true ); } /** * Formats strings as safe HTML. * * @param string $str Input string. * @return string HTML text string safe for post_content. */ protected function get_html_text( $str ) { return $this->sanitize_and_decode_text( $str, false ); } /** * Strip unallowed html tags and decode entities. * * @param string $str Input string. * @param boolean $strip_all_tags Strip all tags, otherwise allow post_content safe tags. * @return string Sanitized and decoded text. */ protected function sanitize_and_decode_text( $str, $strip_all_tags = true ) { // Trim string and return if empty. $str = trim( (string) $str ); if ( empty( $str ) ) { return ''; } if ( $strip_all_tags ) { // Make sure there are no tags. $str = wp_strip_all_tags( $str ); } else { $str = wp_kses_post( $str ); } // Replace all entities with their characters, including all types of quotes. $str = html_entity_decode( $str, ENT_QUOTES ); return $str; } /** * Loads an RSS feed using `fetch_feed`. * * @param boolean $force_refresh Clear the feed cache. * @return SimplePie|WP_Error The RSS object or error. */ public function load_feed( $force_refresh = false ) { // Add action: clear the SimplePie Cache if $force_refresh param is true. if ( true === $force_refresh ) { add_action( 'wp_feed_options', array( __CLASS__, 'reset_simplepie_cache' ) ); } // Add action: detect the podcast feed from the provided feed URL. add_action( 'wp_feed_options', array( __CLASS__, 'set_podcast_locator' ) ); $cache_timeout_filter_added = false; if ( $this->cache_timeout !== null ) { // If we have a custom cache timeout, apply the custom timeout value. add_filter( 'wp_feed_cache_transient_lifetime', array( $this, 'filter_podcast_cache_timeout' ), 20 ); $cache_timeout_filter_added = true; } /** * Allow callers to set up any desired hooks when we fetch the content for a podcast. * The `jetpack_podcast_post_fetch` action can be used to perform cleanup. * * @param string $podcast_url URL for the podcast's RSS feed. * * @since 11.2 */ do_action( 'jetpack_podcast_pre_fetch', $this->feed ); // Fetch the feed. $rss = fetch_feed( $this->feed ); // Remove added actions from wp_feed_options hook. remove_action( 'wp_feed_options', array( __CLASS__, 'set_podcast_locator' ) ); if ( true === $force_refresh ) { remove_action( 'wp_feed_options', array( __CLASS__, 'reset_simplepie_cache' ) ); } if ( $cache_timeout_filter_added ) { // Remove the cache timeout filter we added. remove_filter( 'wp_feed_cache_transient_lifetime', array( $this, 'filter_podcast_cache_timeout' ), 20 ); } /** * Allow callers to identify when we have completed fetching a specified podcast feed. * This makes it possible to clean up any actions or filters that were set up using the * `jetpack_podcast_pre_fetch` action. * * Note that this action runs after other hooks added by Jetpack have been removed. * * @param string $podcast_url URL for the podcast's RSS feed. * @param SimplePie|WP_Error $rss Either the SimplePie RSS object or an error. * * @since 11.2 */ do_action( 'jetpack_podcast_post_fetch', $this->feed, $rss ); if ( is_wp_error( $rss ) ) { return new WP_Error( 'invalid_url', __( 'Your podcast couldn\'t be embedded. Please double check your URL.', 'jetpack' ) ); } if ( ! $rss->get_item_quantity() ) { return new WP_Error( 'no_tracks', __( 'Podcast audio RSS feed has no tracks.', 'jetpack' ) ); } return $rss; } /** * Filter to override the default number of seconds to cache RSS feed data for the current feed. * Note that we don't use the feed's URL because some of the SimplePie feed caches trigger this * filter with a feed identifier and not a URL. * * @param int $cache_timeout_in_seconds Number of seconds to cache the podcast feed. * * @return int The number of seconds to cache the podcast feed. */ public function filter_podcast_cache_timeout( $cache_timeout_in_seconds ) { if ( $this->cache_timeout !== null ) { return $this->cache_timeout; } return $cache_timeout_in_seconds; } /** * Action handler to set our podcast specific feed locator class on the SimplePie object. * * @param SimplePie $feed The SimplePie object, passed by reference. */ public static function set_podcast_locator( &$feed ) { if ( ! class_exists( 'Jetpack_Podcast_Feed_Locator' ) ) { require_once JETPACK__PLUGIN_DIR . '/_inc/lib/class-jetpack-podcast-feed-locator.php'; } $feed->set_locator_class( 'Jetpack_Podcast_Feed_Locator' ); } /** * Action handler to reset the SimplePie cache for the podcast feed. * * Note this only resets the cache for the specified url. If the feed locator finds the podcast feed * within the markup of the that url, that feed itself may still be cached. * * @param SimplePie $feed The SimplePie object, passed by reference. * @return void */ public static function reset_simplepie_cache( &$feed ) { // Retrieve the cache object for a feed url. Based on: // https://github.com/WordPress/WordPress/blob/fd1c2cb4011845ceb7244a062b09b2506082b1c9/wp-includes/class-simplepie.php#L1412. $cache = $feed->registry->call( 'Cache', 'get_handler', array( $feed->cache_location, call_user_func( $feed->cache_name_function, $feed->feed_url ), 'spc' ) ); if ( method_exists( $cache, 'unlink' ) ) { $cache->unlink(); } } /** * Prepares Episode data to be used by the Podcast Player block. * * @param SimplePie_Item $episode SimplePie_Item object, representing a podcast episode. * @return array */ protected function setup_tracks_callback( SimplePie_Item $episode ) { $enclosure = $this->get_audio_enclosure( $episode ); // If the audio enclosure is empty then it is not playable. // We therefore return an empty array for this track. // It will be filtered out later. if ( is_wp_error( $enclosure ) ) { return array(); } // If there is no link return an empty array. We will filter out later. if ( empty( $enclosure->link ) ) { return array(); } $publish_date = $episode->get_gmdate( DATE_ATOM ); // Build track data. $track = array( 'id' => wp_unique_id( 'podcast-track-' ), 'link' => esc_url( $episode->get_link() ), 'src' => esc_url( $enclosure->link ), 'type' => esc_attr( $enclosure->type ), 'description' => $this->get_plain_text( $episode->get_description() ), 'description_html' => $this->get_html_text( $episode->get_description() ), 'title' => $this->get_plain_text( $episode->get_title() ), 'image' => esc_url( $this->get_episode_image_url( $episode ) ), 'guid' => $this->get_plain_text( $episode->get_id() ), 'publish_date' => $publish_date ? $publish_date : null, ); if ( empty( $track['title'] ) ) { $track['title'] = esc_html__( '(no title)', 'jetpack' ); } if ( ! empty( $enclosure->duration ) ) { $track['duration'] = esc_html( $this->format_track_duration( $enclosure->duration ) ); } return $track; } /** * Retrieves an episode's image URL, if it's available. * * @param SimplePie_Item $episode SimplePie_Item object, representing a podcast episode. * @param string $itunes_ns The itunes namespace, defaulted to the standard 1.0 version. * @return string|null The image URL or null if not found. */ protected function get_episode_image_url( SimplePie_Item $episode, $itunes_ns = 'http://www.itunes.com/dtds/podcast-1.0.dtd' ) { $image = $episode->get_item_tags( $itunes_ns, 'image' ); if ( isset( $image[0]['attribs']['']['href'] ) ) { return $image[0]['attribs']['']['href']; } return null; } /** * Retrieves an audio enclosure. * * @param SimplePie_Item $episode SimplePie_Item object, representing a podcast episode. * @return SimplePie_Enclosure|null */ protected function get_audio_enclosure( SimplePie_Item $episode ) { foreach ( (array) $episode->get_enclosures() as $enclosure ) { if ( str_starts_with( $enclosure->type, 'audio/' ) ) { return $enclosure; } } return new WP_Error( 'invalid_audio', __( 'Podcast audio is an invalid type.', 'jetpack' ) ); } /** * Returns the track duration as a formatted string. * * @param int|float $duration of the track in seconds. * @return string */ protected function format_track_duration( $duration ) { $format = $duration > HOUR_IN_SECONDS ? 'H:i:s' : 'i:s'; return date_i18n( $format, $duration ); } /** * Gets podcast player data schema. * * Useful for json schema in REST API endpoints. * * @return array Player data json schema. */ public static function get_player_data_schema() { return array( '$schema' => 'http://json-schema.org/draft-04/schema#', 'title' => 'jetpack-podcast-player-data', 'type' => 'object', 'properties' => array( 'title' => array( 'description' => __( 'The title of the podcast.', 'jetpack' ), 'type' => 'string', ), 'link' => array( 'description' => __( 'The URL of the podcast website.', 'jetpack' ), 'type' => 'string', 'format' => 'uri', ), 'cover' => array( 'description' => __( 'The URL of the podcast cover image.', 'jetpack' ), 'type' => 'string', 'format' => 'uri', ), 'tracks' => self::get_tracks_schema(), 'options' => self::get_options_schema(), ), ); } /** * Gets tracks data schema. * * Useful for json schema in REST API endpoints. * * @return array Tracks json schema. */ public static function get_tracks_schema() { return array( 'description' => __( 'Latest episodes of the podcast.', 'jetpack' ), 'type' => 'array', 'items' => array( 'type' => 'object', 'properties' => array( 'id' => array( 'description' => __( 'The episode id. Generated per request, not globally unique.', 'jetpack' ), 'type' => 'string', ), 'link' => array( 'description' => __( 'The external link for the episode.', 'jetpack' ), 'type' => 'string', 'format' => 'uri', ), 'src' => array( 'description' => __( 'The audio file URL of the episode.', 'jetpack' ), 'type' => 'string', 'format' => 'uri', ), 'type' => array( 'description' => __( 'The mime type of the episode.', 'jetpack' ), 'type' => 'string', ), 'description' => array( 'description' => __( 'The episode description, in plaintext.', 'jetpack' ), 'type' => 'string', ), 'description_html' => array( 'description' => __( 'The episode description with allowed html tags.', 'jetpack' ), 'type' => 'string', ), 'title' => array( 'description' => __( 'The episode title.', 'jetpack' ), 'type' => 'string', ), 'publish_date' => array( 'description' => __( 'The UTC publish date and time of the episode', 'jetpack' ), 'type' => 'string', 'format' => 'date-time', ), ), ), ); } /** * Gets the episode options schema. * * Useful for json schema in REST API endpoints. * * @return array Tracks json schema. */ public static function get_options_schema() { return array( 'description' => __( 'The options that will be displayed in the episode selection UI', 'jetpack' ), 'type' => 'array', 'items' => array( 'type' => 'object', 'properties' => array( 'label' => array( 'description' => __( 'The display label of the option, the episode title.', 'jetpack' ), 'type' => 'string', ), 'value' => array( 'description' => __( 'The value used for that option, the episode GUID', 'jetpack' ), 'type' => 'string', ), ), ), ); } }