| 1 |
<?php |
| 2 |
/** |
| 3 |
* Helper to massage Podcast data to be used in the Podcast block. |
| 4 |
* |
| 5 |
* @package automattic/jetpack |
| 6 |
*/ |
| 7 |
|
| 8 |
/** |
| 9 |
* Class Jetpack_Podcast_Helper |
| 10 |
*/ |
| 11 |
class Jetpack_Podcast_Helper { |
| 12 |
/** |
| 13 |
* How long to wait before retrying a feed that failed to load. Kept out of step with |
| 14 |
* the five minutes WordPress.com caches a feed at the edge, so retries don't keep |
| 15 |
* landing on a cold entry. |
| 16 |
* |
| 17 |
* @var int |
| 18 |
*/ |
| 19 |
const ERROR_CACHE_TIMEOUT = 90; |
| 20 |
|
| 21 |
/** |
| 22 |
* How long to keep the last successful response as a fallback. |
| 23 |
* |
| 24 |
* @var int |
| 25 |
*/ |
| 26 |
const FALLBACK_CACHE_TIMEOUT = WEEK_IN_SECONDS; |
| 27 |
|
| 28 |
/** |
| 29 |
* Feed errors that describe the feed's contents rather than our failure to reach it. |
| 30 |
* |
| 31 |
* @var string[] |
| 32 |
*/ |
| 33 |
const AUTHORITATIVE_ERROR_CODES = array( 'no_tracks' ); |
| 34 |
|
| 35 |
/** |
| 36 |
* The RSS feed of the podcast. |
| 37 |
* |
| 38 |
* @var string |
| 39 |
*/ |
| 40 |
protected $feed = null; |
| 41 |
|
| 42 |
/** |
| 43 |
* The number of seconds to cache the podcast feed data. |
| 44 |
* This value defaults to 1 hour specifically for podcast feeds. |
| 45 |
* The value can be overridden specifically for podcasts using the |
| 46 |
* `jetpack_podcast_feed_cache_timeout` filter. Note that the cache timeout value |
| 47 |
* for all RSS feeds can be modified using the `wp_feed_cache_transient_lifetime` |
| 48 |
* filter from WordPress core. |
| 49 |
* |
| 50 |
* @see https://developer.wordpress.org/reference/hooks/wp_feed_cache_transient_lifetime/ |
| 51 |
* @see WP_Feed_Cache_Transient |
| 52 |
* |
| 53 |
* @var int|null |
| 54 |
*/ |
| 55 |
protected $cache_timeout = HOUR_IN_SECONDS; |
| 56 |
|
| 57 |
/** |
| 58 |
* Initialize class. |
| 59 |
* |
| 60 |
* @param string $feed The RSS feed of the podcast. |
| 61 |
*/ |
| 62 |
public function __construct( $feed ) { |
| 63 |
$this->feed = esc_url_raw( $feed ); |
| 64 |
|
| 65 |
/** |
| 66 |
* 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. |
| 67 |
* Note that this timeout will only work if the site is using the default `WP_Feed_Cache_Transient` cache implementation for RSS feeds, |
| 68 |
* or their cache implementation relies on the `wp_feed_cache_transient_lifetime` filter. |
| 69 |
* |
| 70 |
* @since 11.3 |
| 71 |
* @see https://developer.wordpress.org/reference/hooks/wp_feed_cache_transient_lifetime/ |
| 72 |
* |
| 73 |
* @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. |
| 74 |
* @param string $podcast_url The URL of the podcast feed. |
| 75 |
*/ |
| 76 |
$podcast_cache_timeout = apply_filters( 'jetpack_podcast_feed_cache_timeout', $this->cache_timeout, $this->feed ); |
| 77 |
|
| 78 |
// Make sure we force new values for $this->cache_timeout to be integers. |
| 79 |
if ( is_numeric( $podcast_cache_timeout ) ) { |
| 80 |
$this->cache_timeout = (int) $podcast_cache_timeout; |
| 81 |
} |
| 82 |
} |
| 83 |
|
| 84 |
/** |
| 85 |
* Retrieves tracks quantity. |
| 86 |
* |
| 87 |
* @return int number of tracks |
| 88 |
*/ |
| 89 |
public static function get_tracks_quantity() { |
| 90 |
/** |
| 91 |
* Allow requesting a specific number of tracks from SimplePie's `get_items` call. |
| 92 |
* The default number of tracks is ten. |
| 93 |
* |
| 94 |
* @since 10.4.0 |
| 95 |
* |
| 96 |
* @param int $number Number of tracks fetched. Default is 10. |
| 97 |
*/ |
| 98 |
return (int) apply_filters( 'jetpack_podcast_helper_tracks_quantity', 10 ); |
| 99 |
} |
| 100 |
|
| 101 |
/** |
| 102 |
* Gets podcast data formatted to be used by the Podcast Player block in both server-side |
| 103 |
* block rendering and in API `WPCOM_REST_API_V2_Endpoint_Podcast_Player`. |
| 104 |
* |
| 105 |
* A successful response is cached for one hour, and kept for a week as a fallback to serve |
| 106 |
* while the feed is unreachable. Callers that need the feed's true state can opt out of |
| 107 |
* both with the `report_errors` argument. |
| 108 |
* |
| 109 |
* @param array $args { |
| 110 |
* Optional array of arguments. |
| 111 |
* @type array $guids The IDs of specific episodes to return rather than a list. |
| 112 |
* @type bool $episode-options Whether to include the episode list for the selection UI. |
| 113 |
* @type bool $report_errors Whether to return feed errors as-is rather than falling |
| 114 |
* back to the last successful response. Default false. |
| 115 |
* } |
| 116 |
* |
| 117 |
* @return array|WP_Error The player data or a error object. |
| 118 |
*/ |
| 119 |
public function get_player_data( $args = array() ) { |
| 120 |
$guids = isset( $args['guids'] ) && $args['guids'] ? $args['guids'] : array(); |
| 121 |
$episode_options = isset( $args['episode-options'] ) && $args['episode-options']; |
| 122 |
$report_errors = isset( $args['report_errors'] ) && $args['report_errors']; |
| 123 |
|
| 124 |
// Try loading data from the cache. |
| 125 |
$transient_key = 'jetpack_podcast_' . md5( $this->feed . implode( ',', $guids ) . "-$episode_options" ); |
| 126 |
$player_data = get_transient( $transient_key ); |
| 127 |
|
| 128 |
// A remembered failure would outlive the fix it is asking the author to make. |
| 129 |
if ( $report_errors && is_wp_error( $player_data ) ) { |
| 130 |
$player_data = false; |
| 131 |
} |
| 132 |
|
| 133 |
// Fetch data if we don't have any cached. |
| 134 |
if ( false === $player_data || ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) { |
| 135 |
// Load feed. |
| 136 |
$rss = $this->load_feed(); |
| 137 |
|
| 138 |
if ( is_wp_error( $rss ) ) { |
| 139 |
return $this->handle_failure( $transient_key, $rss, $report_errors ); |
| 140 |
} |
| 141 |
|
| 142 |
// Get a list of episodes by guid or all tracks in feed. |
| 143 |
if ( count( $guids ) ) { |
| 144 |
$tracks = array_map( array( $this, 'get_track_data' ), $guids ); |
| 145 |
$tracks = array_filter( |
| 146 |
$tracks, |
| 147 |
function ( $track ) { |
| 148 |
return ! is_wp_error( $track ); |
| 149 |
} |
| 150 |
); |
| 151 |
} else { |
| 152 |
$tracks = $this->get_track_list(); |
| 153 |
} |
| 154 |
|
| 155 |
if ( is_wp_error( $tracks ) ) { |
| 156 |
return $this->handle_failure( $transient_key, $tracks, $report_errors ); |
| 157 |
} |
| 158 |
|
| 159 |
if ( empty( $tracks ) ) { |
| 160 |
return $this->handle_failure( |
| 161 |
$transient_key, |
| 162 |
new WP_Error( 'no_tracks', __( 'Your Podcast couldn\'t be embedded as it doesn\'t contain any tracks. Please double check your URL.', 'jetpack' ) ), |
| 163 |
$report_errors |
| 164 |
); |
| 165 |
} |
| 166 |
|
| 167 |
// Get podcast meta. |
| 168 |
$title = $rss->get_title(); |
| 169 |
$title = $this->get_plain_text( $title ); |
| 170 |
|
| 171 |
$description = $rss->get_description(); |
| 172 |
$description = $this->get_plain_text( $description ); |
| 173 |
|
| 174 |
$cover = $rss->get_image_url(); |
| 175 |
$cover = ! empty( $cover ) ? esc_url( $cover ) : null; |
| 176 |
|
| 177 |
$link = $rss->get_link(); |
| 178 |
$link = ! empty( $link ) ? esc_url( $link ) : null; |
| 179 |
|
| 180 |
$player_data = array( |
| 181 |
'title' => $title, |
| 182 |
'description' => $description, |
| 183 |
'link' => $link, |
| 184 |
'cover' => $cover, |
| 185 |
'tracks' => $tracks, |
| 186 |
); |
| 187 |
|
| 188 |
if ( $episode_options ) { |
| 189 |
$player_data['options'] = array(); |
| 190 |
foreach ( $rss->get_items() as $episode ) { |
| 191 |
$enclosure = $this->get_audio_enclosure( $episode ); |
| 192 |
// If the episode doesn't have playable audio, then don't include it. |
| 193 |
if ( is_wp_error( $enclosure ) ) { |
| 194 |
continue; |
| 195 |
} |
| 196 |
$player_data['options'][] = array( |
| 197 |
'label' => $this->get_plain_text( $episode->get_title() ), |
| 198 |
'value' => $episode->get_id(), |
| 199 |
); |
| 200 |
} |
| 201 |
} |
| 202 |
|
| 203 |
set_transient( $transient_key, $player_data, HOUR_IN_SECONDS ); |
| 204 |
|
| 205 |
// Callers that asked for errors never read a fallback, so don't pay to write one. |
| 206 |
if ( ! $report_errors ) { |
| 207 |
$this->store_fallback( $transient_key, $player_data ); |
| 208 |
} |
| 209 |
|
| 210 |
return $player_data; |
| 211 |
} |
| 212 |
|
| 213 |
// Only a remembered failure reaches here; a fresh one returns via handle_failure(). |
| 214 |
if ( is_wp_error( $player_data ) ) { |
| 215 |
return $this->fallback_for( $transient_key, $player_data ); |
| 216 |
} |
| 217 |
|
| 218 |
// A response cached before this feed had a fallback still deserves to cover the next |
| 219 |
// outage, rather than leaving a gap until the cache next turns over. |
| 220 |
if ( ! $report_errors ) { |
| 221 |
$this->store_fallback( $transient_key, $player_data, true ); |
| 222 |
} |
| 223 |
|
| 224 |
return $player_data; |
| 225 |
} |
| 226 |
|
| 227 |
/** |
| 228 |
* Keeps a successful response around to serve while the feed is unreachable. |
| 229 |
* |
| 230 |
* @param string $transient_key Cache key for this feed/args combination. |
| 231 |
* @param array $player_data The response to keep. |
| 232 |
* @param bool $only_if_missing Whether to leave an existing fallback in place. |
| 233 |
*/ |
| 234 |
protected function store_fallback( $transient_key, $player_data, $only_if_missing = false ) { |
| 235 |
$fallback_key = static::fallback_key( $transient_key ); |
| 236 |
|
| 237 |
if ( $only_if_missing && false !== get_transient( $fallback_key ) ) { |
| 238 |
return; |
| 239 |
} |
| 240 |
|
| 241 |
set_transient( $fallback_key, $player_data, static::FALLBACK_CACHE_TIMEOUT ); |
| 242 |
} |
| 243 |
|
| 244 |
/** |
| 245 |
* Decides what to serve for a failed fetch, and whether to remember the failure. |
| 246 |
* |
| 247 |
* @param string $transient_key Cache key for this feed/args combination. |
| 248 |
* @param WP_Error $error The error to fall back from. |
| 249 |
* @param bool $report_errors Whether the caller asked for the error itself. |
| 250 |
* @return array|WP_Error The last successful response, or the error. |
| 251 |
*/ |
| 252 |
protected function handle_failure( $transient_key, $error, $report_errors = false ) { |
| 253 |
if ( $report_errors ) { |
| 254 |
return $error; |
| 255 |
} |
| 256 |
|
| 257 |
$fallback = $this->fallback_for( $transient_key, $error ); |
| 258 |
|
| 259 |
// Remembering a failure we can't paper over only delays the retry that would earn us |
| 260 |
// a fallback. An authoritative one is worth remembering either way. |
| 261 |
if ( is_array( $fallback ) || static::is_authoritative( $error ) ) { |
| 262 |
// The error, never the fallback: the editor shares this key and would take stale |
| 263 |
// episodes as a working feed. |
| 264 |
set_transient( $transient_key, $error, static::ERROR_CACHE_TIMEOUT ); |
| 265 |
} |
| 266 |
|
| 267 |
return $fallback; |
| 268 |
} |
| 269 |
|
| 270 |
/** |
| 271 |
* What to serve for a failed fetch: the last successful response, or the error itself. |
| 272 |
* |
| 273 |
* @param string $transient_key Cache key for this feed/args combination. |
| 274 |
* @param WP_Error $error The error to fall back from. |
| 275 |
* @return array|WP_Error The last successful response, or the error. |
| 276 |
*/ |
| 277 |
protected function fallback_for( $transient_key, $error ) { |
| 278 |
// The feed's own answer, so stale episodes have no business standing in for it. They |
| 279 |
// stay stored rather than being deleted: a feed can report itself empty mid-migration, |
| 280 |
// and one such read shouldn't cost the week of cover only a success can restore. |
| 281 |
if ( static::is_authoritative( $error ) ) { |
| 282 |
return $error; |
| 283 |
} |
| 284 |
|
| 285 |
$fallback = get_transient( static::fallback_key( $transient_key ) ); |
| 286 |
|
| 287 |
return is_array( $fallback ) ? $fallback : $error; |
| 288 |
} |
| 289 |
|
| 290 |
/** |
| 291 |
* Whether the error reflects the feed's real contents rather than our failure to |
| 292 |
* reach it. |
| 293 |
* |
| 294 |
* @param WP_Error $error The error to classify. |
| 295 |
* @return bool |
| 296 |
*/ |
| 297 |
protected static function is_authoritative( $error ) { |
| 298 |
return in_array( $error->get_error_code(), static::AUTHORITATIVE_ERROR_CODES, true ); |
| 299 |
} |
| 300 |
|
| 301 |
/** |
| 302 |
* Cache key holding the last successful response for a feed/args combination. |
| 303 |
* |
| 304 |
* @param string $transient_key The regular cache key. |
| 305 |
* @return string |
| 306 |
*/ |
| 307 |
protected static function fallback_key( $transient_key ) { |
| 308 |
return $transient_key . '_last'; |
| 309 |
} |
| 310 |
|
| 311 |
/** |
| 312 |
* Gets a specific track from the supplied feed URL. |
| 313 |
* |
| 314 |
* @param string $guid The GUID of the track. |
| 315 |
* @param boolean $force_refresh Clear the feed cache. |
| 316 |
* @return array|WP_Error The track object or an error object. |
| 317 |
*/ |
| 318 |
public function get_track_data( $guid, $force_refresh = false ) { |
| 319 |
// Get the cache key. |
| 320 |
$transient_key = 'jetpack_podcast_' . md5( "$this->feed::$guid" ); |
| 321 |
|
| 322 |
// Clear the cache if force_refresh param is true. |
| 323 |
if ( true === $force_refresh ) { |
| 324 |
delete_transient( $transient_key ); |
| 325 |
} |
| 326 |
|
| 327 |
// Try loading track data from the cache. |
| 328 |
$track_data = get_transient( $transient_key ); |
| 329 |
|
| 330 |
// Fetch data if we don't have any cached. |
| 331 |
if ( false === $track_data || ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) { |
| 332 |
// Load feed. |
| 333 |
$rss = $this->load_feed( $force_refresh ); |
| 334 |
|
| 335 |
if ( is_wp_error( $rss ) ) { |
| 336 |
return $rss; |
| 337 |
} |
| 338 |
|
| 339 |
// Loop over all tracks to find the one. |
| 340 |
foreach ( $rss->get_items() as $track ) { |
| 341 |
if ( $guid === $track->get_id() ) { |
| 342 |
$track_data = $this->setup_tracks_callback( $track ); |
| 343 |
break; |
| 344 |
} |
| 345 |
} |
| 346 |
|
| 347 |
if ( false === $track_data ) { |
| 348 |
return new WP_Error( 'no_track', __( 'The track was not found.', 'jetpack' ) ); |
| 349 |
} |
| 350 |
|
| 351 |
// Cache for 1 hour. |
| 352 |
set_transient( $transient_key, $track_data, HOUR_IN_SECONDS ); |
| 353 |
} |
| 354 |
|
| 355 |
return $track_data; |
| 356 |
} |
| 357 |
|
| 358 |
/** |
| 359 |
* Gets a list of tracks for the supplied RSS feed. |
| 360 |
* |
| 361 |
* @return array|WP_Error The feed's tracks or a error object. |
| 362 |
*/ |
| 363 |
public function get_track_list() { |
| 364 |
$rss = $this->load_feed(); |
| 365 |
|
| 366 |
if ( is_wp_error( $rss ) ) { |
| 367 |
return $rss; |
| 368 |
} |
| 369 |
|
| 370 |
$tracks_quantity = static::get_tracks_quantity(); |
| 371 |
|
| 372 |
/** |
| 373 |
* Allow requesting a specific number of tracks from SimplePie's `get_items` call. |
| 374 |
* The default number of tracks is ten. |
| 375 |
* Deprecated. Use jetpack_podcast_helper_tracks_quantity filter instead, which takes one less parameter. |
| 376 |
* |
| 377 |
* @since 9.5.0 |
| 378 |
* @deprecated 10.4.0 |
| 379 |
* |
| 380 |
* @param int $tracks_quantity Number of tracks fetched. Default is 10. |
| 381 |
* @param object $rss The SimplePie object built from core's `fetch_feed` call. |
| 382 |
*/ |
| 383 |
$tracks_quantity = apply_filters_deprecated( 'jetpack_podcast_helper_list_quantity', array( $tracks_quantity, $rss ), '10.4.0', 'jetpack_podcast_helper_tracks_quantity' ); |
| 384 |
|
| 385 |
// Process the requested number of items from our feed. |
| 386 |
$track_list = array_map( array( __CLASS__, 'setup_tracks_callback' ), $rss->get_items( 0, $tracks_quantity ) ); |
| 387 |
|
| 388 |
// Filter out any tracks that are empty. |
| 389 |
// Reset the array indices. |
| 390 |
return array_values( array_filter( $track_list ) ); |
| 391 |
} |
| 392 |
|
| 393 |
/** |
| 394 |
* Formats string as pure plaintext, with no HTML tags or entities present. |
| 395 |
* This is ready to be used in React, innerText but needs to be escaped |
| 396 |
* using standard `esc_html` when generating markup on server. |
| 397 |
* |
| 398 |
* @param string $str Input string. |
| 399 |
* @return string Plain text string. |
| 400 |
*/ |
| 401 |
protected function get_plain_text( $str ) { |
| 402 |
return $this->sanitize_and_decode_text( $str, true ); |
| 403 |
} |
| 404 |
|
| 405 |
/** |
| 406 |
* Formats strings as safe HTML. |
| 407 |
* |
| 408 |
* @param string $str Input string. |
| 409 |
* @return string HTML text string safe for post_content. |
| 410 |
*/ |
| 411 |
protected function get_html_text( $str ) { |
| 412 |
return $this->sanitize_and_decode_text( $str, false ); |
| 413 |
} |
| 414 |
|
| 415 |
/** |
| 416 |
* Strip unallowed html tags and decode entities. |
| 417 |
* |
| 418 |
* @param string $str Input string. |
| 419 |
* @param boolean $strip_all_tags Strip all tags, otherwise allow post_content safe tags. |
| 420 |
* @return string Sanitized and decoded text. |
| 421 |
*/ |
| 422 |
protected function sanitize_and_decode_text( $str, $strip_all_tags = true ) { |
| 423 |
// Trim string and return if empty. |
| 424 |
$str = trim( (string) $str ); |
| 425 |
if ( empty( $str ) ) { |
| 426 |
return ''; |
| 427 |
} |
| 428 |
|
| 429 |
if ( $strip_all_tags ) { |
| 430 |
// Make sure there are no tags. |
| 431 |
$str = wp_strip_all_tags( $str ); |
| 432 |
} else { |
| 433 |
$str = wp_kses_post( $str ); |
| 434 |
} |
| 435 |
|
| 436 |
// Replace all entities with their characters, including all types of quotes. |
| 437 |
$str = html_entity_decode( $str, ENT_QUOTES ); |
| 438 |
|
| 439 |
return $str; |
| 440 |
} |
| 441 |
|
| 442 |
/** |
| 443 |
* Loads an RSS feed using `fetch_feed`. |
| 444 |
* |
| 445 |
* @param boolean $force_refresh Clear the feed cache. |
| 446 |
* @return SimplePie\SimplePie|WP_Error The RSS object or error. |
| 447 |
*/ |
| 448 |
public function load_feed( $force_refresh = false ) { |
| 449 |
// Add action: clear the SimplePie Cache if $force_refresh param is true. |
| 450 |
if ( true === $force_refresh ) { |
| 451 |
add_action( 'wp_feed_options', array( __CLASS__, 'reset_simplepie_cache' ) ); |
| 452 |
} |
| 453 |
// Add action: detect the podcast feed from the provided feed URL. |
| 454 |
add_action( 'wp_feed_options', array( __CLASS__, 'set_podcast_locator' ) ); |
| 455 |
|
| 456 |
$cache_timeout_filter_added = false; |
| 457 |
if ( $this->cache_timeout !== null ) { |
| 458 |
// If we have a custom cache timeout, apply the custom timeout value. |
| 459 |
add_filter( 'wp_feed_cache_transient_lifetime', array( $this, 'filter_podcast_cache_timeout' ), 20 ); |
| 460 |
$cache_timeout_filter_added = true; |
| 461 |
} |
| 462 |
|
| 463 |
/** |
| 464 |
* Allow callers to set up any desired hooks when we fetch the content for a podcast. |
| 465 |
* The `jetpack_podcast_post_fetch` action can be used to perform cleanup. |
| 466 |
* |
| 467 |
* @param string $podcast_url URL for the podcast's RSS feed. |
| 468 |
* |
| 469 |
* @since 11.2 |
| 470 |
*/ |
| 471 |
do_action( 'jetpack_podcast_pre_fetch', $this->feed ); |
| 472 |
|
| 473 |
// Fetch the feed. |
| 474 |
$rss = fetch_feed( $this->feed ); |
| 475 |
|
| 476 |
// Remove added actions from wp_feed_options hook. |
| 477 |
remove_action( 'wp_feed_options', array( __CLASS__, 'set_podcast_locator' ) ); |
| 478 |
if ( true === $force_refresh ) { |
| 479 |
remove_action( 'wp_feed_options', array( __CLASS__, 'reset_simplepie_cache' ) ); |
| 480 |
} |
| 481 |
|
| 482 |
if ( $cache_timeout_filter_added ) { |
| 483 |
// Remove the cache timeout filter we added. |
| 484 |
remove_filter( 'wp_feed_cache_transient_lifetime', array( $this, 'filter_podcast_cache_timeout' ), 20 ); |
| 485 |
} |
| 486 |
|
| 487 |
/** |
| 488 |
* Allow callers to identify when we have completed fetching a specified podcast feed. |
| 489 |
* This makes it possible to clean up any actions or filters that were set up using the |
| 490 |
* `jetpack_podcast_pre_fetch` action. |
| 491 |
* |
| 492 |
* Note that this action runs after other hooks added by Jetpack have been removed. |
| 493 |
* |
| 494 |
* @param string $podcast_url URL for the podcast's RSS feed. |
| 495 |
* @param SimplePie\SimplePie|SimplePie|WP_Error $rss Either the SimplePie RSS object or an error. |
| 496 |
* |
| 497 |
* @since 11.2 |
| 498 |
*/ |
| 499 |
do_action( 'jetpack_podcast_post_fetch', $this->feed, $rss ); |
| 500 |
|
| 501 |
if ( is_wp_error( $rss ) ) { |
| 502 |
return new WP_Error( 'invalid_url', __( 'Your podcast couldn\'t be embedded. Please double check your URL.', 'jetpack' ) ); |
| 503 |
} |
| 504 |
|
| 505 |
if ( ! $rss->get_item_quantity() ) { |
| 506 |
return new WP_Error( 'no_tracks', __( 'Podcast audio RSS feed has no tracks.', 'jetpack' ) ); |
| 507 |
} |
| 508 |
|
| 509 |
return $rss; |
| 510 |
} |
| 511 |
|
| 512 |
/** |
| 513 |
* Filter to override the default number of seconds to cache RSS feed data for the current feed. |
| 514 |
* Note that we don't use the feed's URL because some of the SimplePie feed caches trigger this |
| 515 |
* filter with a feed identifier and not a URL. |
| 516 |
* |
| 517 |
* @param int $cache_timeout_in_seconds Number of seconds to cache the podcast feed. |
| 518 |
* |
| 519 |
* @return int The number of seconds to cache the podcast feed. |
| 520 |
*/ |
| 521 |
public function filter_podcast_cache_timeout( $cache_timeout_in_seconds ) { |
| 522 |
if ( $this->cache_timeout !== null ) { |
| 523 |
return $this->cache_timeout; |
| 524 |
} |
| 525 |
|
| 526 |
return $cache_timeout_in_seconds; |
| 527 |
} |
| 528 |
|
| 529 |
/** |
| 530 |
* Action handler to set our podcast specific feed locator class on the SimplePie object. |
| 531 |
* |
| 532 |
* @param SimplePie\SimplePie $feed The SimplePie object, passed by reference. |
| 533 |
*/ |
| 534 |
public static function set_podcast_locator( &$feed ) { |
| 535 |
if ( ! class_exists( 'Jetpack_Podcast_Feed_Locator' ) ) { |
| 536 |
require_once JETPACK__PLUGIN_DIR . '/_inc/lib/class-jetpack-podcast-feed-locator.php'; |
| 537 |
} |
| 538 |
|
| 539 |
$feed->get_registry()->register( SimplePie\Locator::class, 'Jetpack_Podcast_Feed_Locator' ); |
| 540 |
} |
| 541 |
|
| 542 |
/** |
| 543 |
* Action handler to reset the SimplePie cache for the podcast feed. |
| 544 |
* |
| 545 |
* Note this only resets the cache for the specified url. If the feed locator finds the podcast feed |
| 546 |
* within the markup of the that url, that feed itself may still be cached. |
| 547 |
* |
| 548 |
* @param SimplePie\SimplePie $feed The SimplePie object, passed by reference. |
| 549 |
* @return void |
| 550 |
*/ |
| 551 |
public static function reset_simplepie_cache( &$feed ) { |
| 552 |
// Retrieve the cache object for a feed url. Based on: |
| 553 |
// https://github.com/WordPress/WordPress/blob/fd1c2cb4011845ceb7244a062b09b2506082b1c9/wp-includes/class-simplepie.php#L1412. |
| 554 |
// @todo This method of getting the cache is deprecated, and there doesn't seem to be a real replacement. `$feed->get_cache()` is private. |
| 555 |
// @phan-suppress-next-line PhanUndeclaredClassReference |
| 556 |
$cache = $feed->registry->call( 'Cache', 'get_handler', array( $feed->cache_location, call_user_func( $feed->cache_name_function, $feed->feed_url ), 'spc' ) ); |
| 557 |
|
| 558 |
if ( method_exists( $cache, 'unlink' ) ) { |
| 559 |
$cache->unlink(); |
| 560 |
} |
| 561 |
} |
| 562 |
|
| 563 |
/** |
| 564 |
* Prepares Episode data to be used by the Podcast Player block. |
| 565 |
* |
| 566 |
* @param SimplePie\Item $episode SimplePie Item object, representing a podcast episode. |
| 567 |
* @return array |
| 568 |
*/ |
| 569 |
protected function setup_tracks_callback( SimplePie\Item $episode ) { |
| 570 |
$enclosure = $this->get_audio_enclosure( $episode ); |
| 571 |
|
| 572 |
// If the audio enclosure is empty then it is not playable. |
| 573 |
// We therefore return an empty array for this track. |
| 574 |
// It will be filtered out later. |
| 575 |
if ( is_wp_error( $enclosure ) ) { |
| 576 |
return array(); |
| 577 |
} |
| 578 |
|
| 579 |
// If there is no link return an empty array. We will filter out later. |
| 580 |
if ( empty( $enclosure->link ) ) { |
| 581 |
return array(); |
| 582 |
} |
| 583 |
|
| 584 |
$publish_date = $episode->get_gmdate( DATE_ATOM ); |
| 585 |
// Build track data. |
| 586 |
$track = array( |
| 587 |
'id' => wp_unique_id( 'podcast-track-' ), |
| 588 |
'link' => esc_url( $episode->get_link() ), |
| 589 |
'src' => esc_url( (string) $enclosure->link ), |
| 590 |
'type' => esc_attr( (string) $enclosure->type ), |
| 591 |
'description' => $this->get_plain_text( $episode->get_description() ), |
| 592 |
'description_html' => $this->get_html_text( $episode->get_description() ), |
| 593 |
'title' => $this->get_plain_text( $episode->get_title() ), |
| 594 |
'image' => esc_url( $this->get_episode_image_url( $episode ) ), |
| 595 |
'guid' => $this->get_plain_text( $episode->get_id() ), |
| 596 |
'publish_date' => $publish_date ? $publish_date : null, |
| 597 |
); |
| 598 |
|
| 599 |
if ( empty( $track['title'] ) ) { |
| 600 |
$track['title'] = esc_html__( '(no title)', 'jetpack' ); |
| 601 |
} |
| 602 |
|
| 603 |
if ( ! empty( $enclosure->duration ) ) { |
| 604 |
$track['duration'] = esc_html( $this->format_track_duration( (int) $enclosure->duration ) ); |
| 605 |
} |
| 606 |
|
| 607 |
return $track; |
| 608 |
} |
| 609 |
|
| 610 |
/** |
| 611 |
* Retrieves an episode's image URL, if it's available. |
| 612 |
* |
| 613 |
* @param SimplePie\Item $episode SimplePie Item object, representing a podcast episode. |
| 614 |
* @param string $itunes_ns The itunes namespace, defaulted to the standard 1.0 version. |
| 615 |
* @return string|null The image URL or null if not found. |
| 616 |
*/ |
| 617 |
protected function get_episode_image_url( SimplePie\Item $episode, $itunes_ns = 'http://www.itunes.com/dtds/podcast-1.0.dtd' ) { |
| 618 |
$image = $episode->get_item_tags( $itunes_ns, 'image' ); |
| 619 |
if ( isset( $image[0]['attribs']['']['href'] ) ) { |
| 620 |
return $image[0]['attribs']['']['href']; |
| 621 |
} |
| 622 |
return null; |
| 623 |
} |
| 624 |
|
| 625 |
/** |
| 626 |
* Retrieves an audio enclosure. |
| 627 |
* |
| 628 |
* @param SimplePie\Item $episode SimplePie Item object, representing a podcast episode. |
| 629 |
* @return SimplePie\Enclosure|null |
| 630 |
*/ |
| 631 |
protected function get_audio_enclosure( SimplePie\Item $episode ) { |
| 632 |
foreach ( (array) $episode->get_enclosures() as $enclosure ) { |
| 633 |
if ( str_starts_with( $enclosure->type ?? '', 'audio/' ) ) { |
| 634 |
return $enclosure; |
| 635 |
} |
| 636 |
} |
| 637 |
|
| 638 |
return new WP_Error( 'invalid_audio', __( 'Podcast audio is an invalid type.', 'jetpack' ) ); |
| 639 |
} |
| 640 |
|
| 641 |
/** |
| 642 |
* Returns the track duration as a formatted string. |
| 643 |
* |
| 644 |
* @param int|float $duration of the track in seconds. |
| 645 |
* @return string |
| 646 |
*/ |
| 647 |
protected function format_track_duration( $duration ) { |
| 648 |
$format = $duration > HOUR_IN_SECONDS ? 'H:i:s' : 'i:s'; |
| 649 |
|
| 650 |
return date_i18n( $format, $duration ); |
| 651 |
} |
| 652 |
|
| 653 |
/** |
| 654 |
* Gets podcast player data schema. |
| 655 |
* |
| 656 |
* Useful for json schema in REST API endpoints. |
| 657 |
* |
| 658 |
* @return array Player data json schema. |
| 659 |
*/ |
| 660 |
public static function get_player_data_schema() { |
| 661 |
return array( |
| 662 |
'$schema' => 'http://json-schema.org/draft-04/schema#', |
| 663 |
'title' => 'jetpack-podcast-player-data', |
| 664 |
'type' => 'object', |
| 665 |
'properties' => array( |
| 666 |
'title' => array( |
| 667 |
'description' => __( 'The title of the podcast.', 'jetpack' ), |
| 668 |
'type' => 'string', |
| 669 |
), |
| 670 |
'link' => array( |
| 671 |
'description' => __( 'The URL of the podcast website.', 'jetpack' ), |
| 672 |
'type' => 'string', |
| 673 |
'format' => 'uri', |
| 674 |
), |
| 675 |
'cover' => array( |
| 676 |
'description' => __( 'The URL of the podcast cover image.', 'jetpack' ), |
| 677 |
'type' => 'string', |
| 678 |
'format' => 'uri', |
| 679 |
), |
| 680 |
'tracks' => self::get_tracks_schema(), |
| 681 |
'options' => self::get_options_schema(), |
| 682 |
), |
| 683 |
); |
| 684 |
} |
| 685 |
|
| 686 |
/** |
| 687 |
* Gets tracks data schema. |
| 688 |
* |
| 689 |
* Useful for json schema in REST API endpoints. |
| 690 |
* |
| 691 |
* @return array Tracks json schema. |
| 692 |
*/ |
| 693 |
public static function get_tracks_schema() { |
| 694 |
return array( |
| 695 |
'description' => __( 'Latest episodes of the podcast.', 'jetpack' ), |
| 696 |
'type' => 'array', |
| 697 |
'items' => array( |
| 698 |
'type' => 'object', |
| 699 |
'properties' => array( |
| 700 |
'id' => array( |
| 701 |
'description' => __( 'The episode id. Generated per request, not globally unique.', 'jetpack' ), |
| 702 |
'type' => 'string', |
| 703 |
), |
| 704 |
'link' => array( |
| 705 |
'description' => __( 'The external link for the episode.', 'jetpack' ), |
| 706 |
'type' => 'string', |
| 707 |
'format' => 'uri', |
| 708 |
), |
| 709 |
'src' => array( |
| 710 |
'description' => __( 'The audio file URL of the episode.', 'jetpack' ), |
| 711 |
'type' => 'string', |
| 712 |
'format' => 'uri', |
| 713 |
), |
| 714 |
'type' => array( |
| 715 |
'description' => __( 'The mime type of the episode.', 'jetpack' ), |
| 716 |
'type' => 'string', |
| 717 |
), |
| 718 |
'description' => array( |
| 719 |
'description' => __( 'The episode description, in plaintext.', 'jetpack' ), |
| 720 |
'type' => 'string', |
| 721 |
), |
| 722 |
'description_html' => array( |
| 723 |
'description' => __( 'The episode description with allowed html tags.', 'jetpack' ), |
| 724 |
'type' => 'string', |
| 725 |
), |
| 726 |
'title' => array( |
| 727 |
'description' => __( 'The episode title.', 'jetpack' ), |
| 728 |
'type' => 'string', |
| 729 |
), |
| 730 |
'publish_date' => array( |
| 731 |
'description' => __( 'The UTC publish date and time of the episode', 'jetpack' ), |
| 732 |
'type' => 'string', |
| 733 |
'format' => 'date-time', |
| 734 |
), |
| 735 |
), |
| 736 |
), |
| 737 |
); |
| 738 |
} |
| 739 |
|
| 740 |
/** |
| 741 |
* Gets the episode options schema. |
| 742 |
* |
| 743 |
* Useful for json schema in REST API endpoints. |
| 744 |
* |
| 745 |
* @return array Tracks json schema. |
| 746 |
*/ |
| 747 |
public static function get_options_schema() { |
| 748 |
return array( |
| 749 |
'description' => __( 'The options that will be displayed in the episode selection UI', 'jetpack' ), |
| 750 |
'type' => 'array', |
| 751 |
'items' => array( |
| 752 |
'type' => 'object', |
| 753 |
'properties' => array( |
| 754 |
'label' => array( |
| 755 |
'description' => __( 'The display label of the option, the episode title.', 'jetpack' ), |
| 756 |
'type' => 'string', |
| 757 |
), |
| 758 |
'value' => array( |
| 759 |
'description' => __( 'The value used for that option, the episode GUID', 'jetpack' ), |
| 760 |
'type' => 'string', |
| 761 |
), |
| 762 |
), |
| 763 |
), |
| 764 |
); |
| 765 |
} |
| 766 |
} |
| 767 |
|