| @@ -9,8 +9,31 @@ | ||
| 9 | 9 | * Class Jetpack_Podcast_Helper |
| 10 | 10 | */ |
| 11 | 11 | class Jetpack_Podcast_Helper { |
| 12 | 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 | + /** | |
| 13 | 36 | * The RSS feed of the podcast. |
| 14 | 37 | * |
| 15 | 38 | * @var string |
| 16 | 39 | */ |
| @@ -60,9 +83,9 @@ | ||
| 60 | 83 | |
| 61 | 84 | /** |
| 62 | 85 | * Retrieves tracks quantity. |
| 63 | 86 | * |
| 64 | - * @returns int number of tracks | |
| 87 | + * @return int number of tracks | |
| 65 | 88 | */ |
| 66 | 89 | public static function get_tracks_quantity() { |
| 67 | 90 | /** |
| 68 | 91 | * Allow requesting a specific number of tracks from SimplePie's `get_items` call. |
| @@ -78,13 +101,18 @@ | ||
| 78 | 101 | /** |
| 79 | 102 | * Gets podcast data formatted to be used by the Podcast Player block in both server-side |
| 80 | 103 | * block rendering and in API `WPCOM_REST_API_V2_Endpoint_Podcast_Player`. |
| 81 | 104 | * |
| 82 | - * The result is cached for one hour. | |
| 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. | |
| 83 | 108 | * |
| 84 | 109 | * @param array $args { |
| 85 | 110 | * Optional array of arguments. |
| 86 | - * @type string|int $guid The ID of a specific episode to return rather than a list. | |
| 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. | |
| 87 | 115 | * } |
| 88 | 116 | * |
| 89 | 117 | * @return array|WP_Error The player data or a error object. |
| 90 | 118 | */ |
| @@ -90,13 +118,19 @@ | ||
| 90 | 118 | */ |
| 91 | 119 | public function get_player_data( $args = array() ) { |
| 92 | 120 | $guids = isset( $args['guids'] ) && $args['guids'] ? $args['guids'] : array(); |
| 93 | 121 | $episode_options = isset( $args['episode-options'] ) && $args['episode-options']; |
| 122 | + $report_errors = isset( $args['report_errors'] ) && $args['report_errors']; | |
| 94 | 123 | |
| 95 | 124 | // Try loading data from the cache. |
| 96 | 125 | $transient_key = 'jetpack_podcast_' . md5( $this->feed . implode( ',', $guids ) . "-$episode_options" ); |
| 97 | 126 | $player_data = get_transient( $transient_key ); |
| 98 | 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 | + | |
| 99 | 133 | // Fetch data if we don't have any cached. |
| 100 | 134 | if ( false === $player_data || ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) { |
| 101 | 135 | // Load feed. |
| 102 | 136 | $rss = $this->load_feed(); |
| @@ -101,9 +135,9 @@ | ||
| 101 | 135 | // Load feed. |
| 102 | 136 | $rss = $this->load_feed(); |
| 103 | 137 | |
| 104 | 138 | if ( is_wp_error( $rss ) ) { |
| 105 | - return $rss; | |
| 139 | + return $this->handle_failure( $transient_key, $rss, $report_errors ); | |
| 106 | 140 | } |
| 107 | 141 | |
| 108 | 142 | // Get a list of episodes by guid or all tracks in feed. |
| 109 | 143 | if ( count( $guids ) ) { |
| @@ -117,10 +151,18 @@ | ||
| 117 | 151 | } else { |
| 118 | 152 | $tracks = $this->get_track_list(); |
| 119 | 153 | } |
| 120 | 154 | |
| 155 | + if ( is_wp_error( $tracks ) ) { | |
| 156 | + return $this->handle_failure( $transient_key, $tracks, $report_errors ); | |
| 157 | + } | |
| 158 | + | |
| 121 | 159 | if ( empty( $tracks ) ) { |
| 122 | - 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' ) ); | |
| 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 | + ); | |
| 123 | 165 | } |
| 124 | 166 | |
| 125 | 167 | // Get podcast meta. |
| 126 | 168 | $title = $rss->get_title(); |
| @@ -157,16 +199,117 @@ | ||
| 157 | 199 | ); |
| 158 | 200 | } |
| 159 | 201 | } |
| 160 | 202 | |
| 161 | - // Cache for 1 hour. | |
| 162 | 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; | |
| 163 | 211 | } |
| 164 | 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 | + | |
| 165 | 224 | return $player_data; |
| 166 | 225 | } |
| 167 | 226 | |
| 168 | 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 | + /** | |
| 169 | 312 | * Gets a specific track from the supplied feed URL. |
| 170 | 313 | * |
| 171 | 314 | * @param string $guid The GUID of the track. |
| 172 | 315 | * @param boolean $force_refresh Clear the feed cache. |
| @@ -242,9 +385,9 @@ | ||
| 242 | 385 | // Process the requested number of items from our feed. |
| 243 | 386 | $track_list = array_map( array( __CLASS__, 'setup_tracks_callback' ), $rss->get_items( 0, $tracks_quantity ) ); |
| 244 | 387 | |
| 245 | 388 | // Filter out any tracks that are empty. |
| 246 | - // Reset the array indicies. | |
| 389 | + // Reset the array indices. | |
| 247 | 390 | return array_values( array_filter( $track_list ) ); |
| 248 | 391 | } |
| 249 | 392 | |
| 250 | 393 | /** |
| @@ -299,9 +442,9 @@ | ||
| 299 | 442 | /** |
| 300 | 443 | * Loads an RSS feed using `fetch_feed`. |
| 301 | 444 | * |
| 302 | 445 | * @param boolean $force_refresh Clear the feed cache. |
| 303 | - * @return SimplePie|WP_Error The RSS object or error. | |
| 446 | + * @return SimplePie\SimplePie|WP_Error The RSS object or error. | |
| 304 | 447 | */ |
| 305 | 448 | public function load_feed( $force_refresh = false ) { |
| 306 | 449 | // Add action: clear the SimplePie Cache if $force_refresh param is true. |
| 307 | 450 | if ( true === $force_refresh ) { |
| @@ -348,9 +491,9 @@ | ||
| 348 | 491 | * |
| 349 | 492 | * Note that this action runs after other hooks added by Jetpack have been removed. |
| 350 | 493 | * |
| 351 | 494 | * @param string $podcast_url URL for the podcast's RSS feed. |
| 352 | - * @param SimplePie|WP_Error $rss Either the SimplePie RSS object or an error. | |
| 495 | + * @param SimplePie\SimplePie|SimplePie|WP_Error $rss Either the SimplePie RSS object or an error. | |
| 353 | 496 | * |
| 354 | 497 | * @since 11.2 |
| 355 | 498 | */ |
| 356 | 499 | do_action( 'jetpack_podcast_post_fetch', $this->feed, $rss ); |
| @@ -385,9 +528,9 @@ | ||
| 385 | 528 | |
| 386 | 529 | /** |
| 387 | 530 | * Action handler to set our podcast specific feed locator class on the SimplePie object. |
| 388 | 531 | * |
| 389 | - * @param SimplePie $feed The SimplePie object, passed by reference. | |
| 532 | + * @param SimplePie\SimplePie $feed The SimplePie object, passed by reference. | |
| 390 | 533 | */ |
| 391 | 534 | public static function set_podcast_locator( &$feed ) { |
| 392 | 535 | if ( ! class_exists( 'Jetpack_Podcast_Feed_Locator' ) ) { |
| 393 | 536 | require_once JETPACK__PLUGIN_DIR . '/_inc/lib/class-jetpack-podcast-feed-locator.php'; |
| @@ -392,9 +535,9 @@ | ||
| 392 | 535 | if ( ! class_exists( 'Jetpack_Podcast_Feed_Locator' ) ) { |
| 393 | 536 | require_once JETPACK__PLUGIN_DIR . '/_inc/lib/class-jetpack-podcast-feed-locator.php'; |
| 394 | 537 | } |
| 395 | 538 | |
| 396 | - $feed->set_locator_class( 'Jetpack_Podcast_Feed_Locator' ); | |
| 539 | + $feed->get_registry()->register( SimplePie\Locator::class, 'Jetpack_Podcast_Feed_Locator' ); | |
| 397 | 540 | } |
| 398 | 541 | |
| 399 | 542 | /** |
| 400 | 543 | * Action handler to reset the SimplePie cache for the podcast feed. |
| @@ -401,14 +544,16 @@ | ||
| 401 | 544 | * |
| 402 | 545 | * Note this only resets the cache for the specified url. If the feed locator finds the podcast feed |
| 403 | 546 | * within the markup of the that url, that feed itself may still be cached. |
| 404 | 547 | * |
| 405 | - * @param SimplePie $feed The SimplePie object, passed by reference. | |
| 548 | + * @param SimplePie\SimplePie $feed The SimplePie object, passed by reference. | |
| 406 | 549 | * @return void |
| 407 | 550 | */ |
| 408 | 551 | public static function reset_simplepie_cache( &$feed ) { |
| 409 | 552 | // Retrieve the cache object for a feed url. Based on: |
| 410 | 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 | |
| 411 | 556 | $cache = $feed->registry->call( 'Cache', 'get_handler', array( $feed->cache_location, call_user_func( $feed->cache_name_function, $feed->feed_url ), 'spc' ) ); |
| 412 | 557 | |
| 413 | 558 | if ( method_exists( $cache, 'unlink' ) ) { |
| 414 | 559 | $cache->unlink(); |
| @@ -417,12 +562,12 @@ | ||
| 417 | 562 | |
| 418 | 563 | /** |
| 419 | 564 | * Prepares Episode data to be used by the Podcast Player block. |
| 420 | 565 | * |
| 421 | - * @param SimplePie_Item $episode SimplePie_Item object, representing a podcast episode. | |
| 566 | + * @param SimplePie\Item $episode SimplePie Item object, representing a podcast episode. | |
| 422 | 567 | * @return array |
| 423 | 568 | */ |
| 424 | - protected function setup_tracks_callback( SimplePie_Item $episode ) { | |
| 569 | + protected function setup_tracks_callback( SimplePie\Item $episode ) { | |
| 425 | 570 | $enclosure = $this->get_audio_enclosure( $episode ); |
| 426 | 571 | |
| 427 | 572 | // If the audio enclosure is empty then it is not playable. |
| 428 | 573 | // We therefore return an empty array for this track. |
| @@ -440,10 +585,10 @@ | ||
| 440 | 585 | // Build track data. |
| 441 | 586 | $track = array( |
| 442 | 587 | 'id' => wp_unique_id( 'podcast-track-' ), |
| 443 | 588 | 'link' => esc_url( $episode->get_link() ), |
| 444 | - 'src' => esc_url( $enclosure->link ), | |
| 445 | - 'type' => esc_attr( $enclosure->type ), | |
| 589 | + 'src' => esc_url( (string) $enclosure->link ), | |
| 590 | + 'type' => esc_attr( (string) $enclosure->type ), | |
| 446 | 591 | 'description' => $this->get_plain_text( $episode->get_description() ), |
| 447 | 592 | 'description_html' => $this->get_html_text( $episode->get_description() ), |
| 448 | 593 | 'title' => $this->get_plain_text( $episode->get_title() ), |
| 449 | 594 | 'image' => esc_url( $this->get_episode_image_url( $episode ) ), |
| @@ -455,9 +600,9 @@ | ||
| 455 | 600 | $track['title'] = esc_html__( '(no title)', 'jetpack' ); |
| 456 | 601 | } |
| 457 | 602 | |
| 458 | 603 | if ( ! empty( $enclosure->duration ) ) { |
| 459 | - $track['duration'] = esc_html( $this->format_track_duration( $enclosure->duration ) ); | |
| 604 | + $track['duration'] = esc_html( $this->format_track_duration( (int) $enclosure->duration ) ); | |
| 460 | 605 | } |
| 461 | 606 | |
| 462 | 607 | return $track; |
| 463 | 608 | } |
| @@ -464,13 +609,13 @@ | ||
| 464 | 609 | |
| 465 | 610 | /** |
| 466 | 611 | * Retrieves an episode's image URL, if it's available. |
| 467 | 612 | * |
| 468 | - * @param SimplePie_Item $episode SimplePie_Item object, representing a podcast episode. | |
| 613 | + * @param SimplePie\Item $episode SimplePie Item object, representing a podcast episode. | |
| 469 | 614 | * @param string $itunes_ns The itunes namespace, defaulted to the standard 1.0 version. |
| 470 | 615 | * @return string|null The image URL or null if not found. |
| 471 | 616 | */ |
| 472 | - protected function get_episode_image_url( SimplePie_Item $episode, $itunes_ns = 'http://www.itunes.com/dtds/podcast-1.0.dtd' ) { | |
| 617 | + protected function get_episode_image_url( SimplePie\Item $episode, $itunes_ns = 'http://www.itunes.com/dtds/podcast-1.0.dtd' ) { | |
| 473 | 618 | $image = $episode->get_item_tags( $itunes_ns, 'image' ); |
| 474 | 619 | if ( isset( $image[0]['attribs']['']['href'] ) ) { |
| 475 | 620 | return $image[0]['attribs']['']['href']; |
| 476 | 621 | } |
| @@ -479,14 +624,14 @@ | ||
| 479 | 624 | |
| 480 | 625 | /** |
| 481 | 626 | * Retrieves an audio enclosure. |
| 482 | 627 | * |
| 483 | - * @param SimplePie_Item $episode SimplePie_Item object, representing a podcast episode. | |
| 484 | - * @return SimplePie_Enclosure|null | |
| 628 | + * @param SimplePie\Item $episode SimplePie Item object, representing a podcast episode. | |
| 629 | + * @return SimplePie\Enclosure|null | |
| 485 | 630 | */ |
| 486 | - protected function get_audio_enclosure( SimplePie_Item $episode ) { | |
| 631 | + protected function get_audio_enclosure( SimplePie\Item $episode ) { | |
| 487 | 632 | foreach ( (array) $episode->get_enclosures() as $enclosure ) { |
| 488 | - if ( 0 === strpos( $enclosure->type, 'audio/' ) ) { | |
| 633 | + if ( str_starts_with( $enclosure->type ?? '', 'audio/' ) ) { | |
| 489 | 634 | return $enclosure; |
| 490 | 635 | } |
| 491 | 636 | } |
| 492 | 637 | |
| @@ -495,9 +640,9 @@ | ||
| 495 | 640 | |
| 496 | 641 | /** |
| 497 | 642 | * Returns the track duration as a formatted string. |
| 498 | 643 | * |
| 499 | - * @param number $duration of the track in seconds. | |
| 644 | + * @param int|float $duration of the track in seconds. | |
| 500 | 645 | * @return string |
| 501 | 646 | */ |
| 502 | 647 | protected function format_track_duration( $duration ) { |
| 503 | 648 | $format = $duration > HOUR_IN_SECONDS ? 'H:i:s' : 'i:s'; |