Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
5.73% covered (danger)
5.73%
15 / 262
0.00% covered (danger)
0.00%
0 / 24
CRAP
0.00% covered (danger)
0.00%
0 / 1
Jetpack_Podcast_Helper
5.73% covered (danger)
5.73%
15 / 262
0.00% covered (danger)
0.00%
0 / 24
5715.97
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 get_tracks_quantity
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_player_data
0.00% covered (danger)
0.00%
0 / 62
0.00% covered (danger)
0.00%
0 / 1
506
 store_fallback
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 handle_failure
0.00% covered (danger)
0.00%
0 / 6
0.00% covered (danger)
0.00%
0 / 1
20
 fallback_for
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 is_authoritative
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 fallback_key
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_track_data
93.75% covered (success)
93.75%
15 / 16
0.00% covered (danger)
0.00%
0 / 1
9.02
 get_track_list
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 get_plain_text
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_html_text
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 sanitize_and_decode_text
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 load_feed
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
56
 filter_podcast_cache_timeout
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 set_podcast_locator
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 reset_simplepie_cache
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
6
 setup_tracks_callback
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
42
 get_episode_image_url
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 get_audio_enclosure
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 format_track_duration
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 get_player_data_schema
0.00% covered (danger)
0.00%
0 / 23
0.00% covered (danger)
0.00%
0 / 1
2
 get_tracks_schema
0.00% covered (danger)
0.00%
0 / 44
0.00% covered (danger)
0.00%
0 / 1
2
 get_options_schema
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
2
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 */
11class 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}