PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
← All changes | _inc/lib/class-jetpack-podcast-helper.php +766 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,766 @@
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 +}