class-jetpack-podcast-helper.php
19.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
<?php
/**
* Helper to massage Podcast data to be used in the Podcast block.
*
* @package automattic/jetpack
*/
/**
* Class Jetpack_Podcast_Helper
*/
class Jetpack_Podcast_Helper {
/**
* The RSS feed of the podcast.
*
* @var string
*/
protected $feed = null;
/**
* The number of seconds to cache the podcast feed data.
* This value defaults to 1 hour specifically for podcast feeds.
* The value can be overridden specifically for podcasts using the
* `jetpack_podcast_feed_cache_timeout` filter. Note that the cache timeout value
* for all RSS feeds can be modified using the `wp_feed_cache_transient_lifetime`
* filter from WordPress core.
*
* @see https://developer.wordpress.org/reference/hooks/wp_feed_cache_transient_lifetime/
* @see WP_Feed_Cache_Transient
*
* @var int|null
*/
protected $cache_timeout = HOUR_IN_SECONDS;
/**
* Initialize class.
*
* @param string $feed The RSS feed of the podcast.
*/
public function __construct( $feed ) {
$this->feed = esc_url_raw( $feed );
/**
* Filter the number of seconds to cache a specific podcast URL for. The returned value will be ignored if it is null or not a valid integer.
* Note that this timeout will only work if the site is using the default `WP_Feed_Cache_Transient` cache implementation for RSS feeds,
* or their cache implementation relies on the `wp_feed_cache_transient_lifetime` filter.
*
* @since 11.3
* @see https://developer.wordpress.org/reference/hooks/wp_feed_cache_transient_lifetime/
*
* @param int|null $cache_timeout The number of seconds to cache the podcast data. Default value is null, so we don't override any defaults from existing filters.
* @param string $podcast_url The URL of the podcast feed.
*/
$podcast_cache_timeout = apply_filters( 'jetpack_podcast_feed_cache_timeout', $this->cache_timeout, $this->feed );
// Make sure we force new values for $this->cache_timeout to be integers.
if ( is_numeric( $podcast_cache_timeout ) ) {
$this->cache_timeout = (int) $podcast_cache_timeout;
}
}
/**
* Retrieves tracks quantity.
*
* @returns int number of tracks
*/
public static function get_tracks_quantity() {
/**
* Allow requesting a specific number of tracks from SimplePie's `get_items` call.
* The default number of tracks is ten.
*
* @since 10.4.0
*
* @param int $number Number of tracks fetched. Default is 10.
*/
return (int) apply_filters( 'jetpack_podcast_helper_tracks_quantity', 10 );
}
/**
* Gets podcast data formatted to be used by the Podcast Player block in both server-side
* block rendering and in API `WPCOM_REST_API_V2_Endpoint_Podcast_Player`.
*
* The result is cached for one hour.
*
* @param array $args {
* Optional array of arguments.
* @type string|int $guid The ID of a specific episode to return rather than a list.
* }
*
* @return array|WP_Error The player data or a error object.
*/
public function get_player_data( $args = array() ) {
$guids = isset( $args['guids'] ) && $args['guids'] ? $args['guids'] : array();
$episode_options = isset( $args['episode-options'] ) && $args['episode-options'];
// Try loading data from the cache.
$transient_key = 'jetpack_podcast_' . md5( $this->feed . implode( ',', $guids ) . "-$episode_options" );
$player_data = get_transient( $transient_key );
// Fetch data if we don't have any cached.
if ( false === $player_data || ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) {
// Load feed.
$rss = $this->load_feed();
if ( is_wp_error( $rss ) ) {
return $rss;
}
// Get a list of episodes by guid or all tracks in feed.
if ( count( $guids ) ) {
$tracks = array_map( array( $this, 'get_track_data' ), $guids );
$tracks = array_filter(
$tracks,
function ( $track ) {
return ! is_wp_error( $track );
}
);
} else {
$tracks = $this->get_track_list();
}
if ( empty( $tracks ) ) {
return new WP_Error( 'no_tracks', __( 'Your Podcast couldn\'t be embedded as it doesn\'t contain any tracks. Please double check your URL.', 'jetpack' ) );
}
// Get podcast meta.
$title = $rss->get_title();
$title = $this->get_plain_text( $title );
$description = $rss->get_description();
$description = $this->get_plain_text( $description );
$cover = $rss->get_image_url();
$cover = ! empty( $cover ) ? esc_url( $cover ) : null;
$link = $rss->get_link();
$link = ! empty( $link ) ? esc_url( $link ) : null;
$player_data = array(
'title' => $title,
'description' => $description,
'link' => $link,
'cover' => $cover,
'tracks' => $tracks,
);
if ( $episode_options ) {
$player_data['options'] = array();
foreach ( $rss->get_items() as $episode ) {
$enclosure = $this->get_audio_enclosure( $episode );
// If the episode doesn't have playable audio, then don't include it.
if ( is_wp_error( $enclosure ) ) {
continue;
}
$player_data['options'][] = array(
'label' => $this->get_plain_text( $episode->get_title() ),
'value' => $episode->get_id(),
);
}
}
// Cache for 1 hour.
set_transient( $transient_key, $player_data, HOUR_IN_SECONDS );
}
return $player_data;
}
/**
* Gets a specific track from the supplied feed URL.
*
* @param string $guid The GUID of the track.
* @param boolean $force_refresh Clear the feed cache.
* @return array|WP_Error The track object or an error object.
*/
public function get_track_data( $guid, $force_refresh = false ) {
// Get the cache key.
$transient_key = 'jetpack_podcast_' . md5( "$this->feed::$guid" );
// Clear the cache if force_refresh param is true.
if ( true === $force_refresh ) {
delete_transient( $transient_key );
}
// Try loading track data from the cache.
$track_data = get_transient( $transient_key );
// Fetch data if we don't have any cached.
if ( false === $track_data || ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) {
// Load feed.
$rss = $this->load_feed( $force_refresh );
if ( is_wp_error( $rss ) ) {
return $rss;
}
// Loop over all tracks to find the one.
foreach ( $rss->get_items() as $track ) {
if ( $guid === $track->get_id() ) {
$track_data = $this->setup_tracks_callback( $track );
break;
}
}
if ( false === $track_data ) {
return new WP_Error( 'no_track', __( 'The track was not found.', 'jetpack' ) );
}
// Cache for 1 hour.
set_transient( $transient_key, $track_data, HOUR_IN_SECONDS );
}
return $track_data;
}
/**
* Gets a list of tracks for the supplied RSS feed.
*
* @return array|WP_Error The feed's tracks or a error object.
*/
public function get_track_list() {
$rss = $this->load_feed();
if ( is_wp_error( $rss ) ) {
return $rss;
}
$tracks_quantity = $this->get_tracks_quantity();
/**
* Allow requesting a specific number of tracks from SimplePie's `get_items` call.
* The default number of tracks is ten.
* Deprecated. Use jetpack_podcast_helper_tracks_quantity filter instead, which takes one less parameter.
*
* @since 9.5.0
* @deprecated 10.4.0
*
* @param int $tracks_quantity Number of tracks fetched. Default is 10.
* @param object $rss The SimplePie object built from core's `fetch_feed` call.
*/
$tracks_quantity = apply_filters_deprecated( 'jetpack_podcast_helper_list_quantity', array( $tracks_quantity, $rss ), '10.4.0', 'jetpack_podcast_helper_tracks_quantity' );
// Process the requested number of items from our feed.
$track_list = array_map( array( __CLASS__, 'setup_tracks_callback' ), $rss->get_items( 0, $tracks_quantity ) );
// Filter out any tracks that are empty.
// Reset the array indicies.
return array_values( array_filter( $track_list ) );
}
/**
* Formats string as pure plaintext, with no HTML tags or entities present.
* This is ready to be used in React, innerText but needs to be escaped
* using standard `esc_html` when generating markup on server.
*
* @param string $str Input string.
* @return string Plain text string.
*/
protected function get_plain_text( $str ) {
return $this->sanitize_and_decode_text( $str, true );
}
/**
* Formats strings as safe HTML.
*
* @param string $str Input string.
* @return string HTML text string safe for post_content.
*/
protected function get_html_text( $str ) {
return $this->sanitize_and_decode_text( $str, false );
}
/**
* Strip unallowed html tags and decode entities.
*
* @param string $str Input string.
* @param boolean $strip_all_tags Strip all tags, otherwise allow post_content safe tags.
* @return string Sanitized and decoded text.
*/
protected function sanitize_and_decode_text( $str, $strip_all_tags = true ) {
// Trim string and return if empty.
$str = trim( (string) $str );
if ( empty( $str ) ) {
return '';
}
if ( $strip_all_tags ) {
// Make sure there are no tags.
$str = wp_strip_all_tags( $str );
} else {
$str = wp_kses_post( $str );
}
// Replace all entities with their characters, including all types of quotes.
$str = html_entity_decode( $str, ENT_QUOTES );
return $str;
}
/**
* Loads an RSS feed using `fetch_feed`.
*
* @param boolean $force_refresh Clear the feed cache.
* @return SimplePie|WP_Error The RSS object or error.
*/
public function load_feed( $force_refresh = false ) {
// Add action: clear the SimplePie Cache if $force_refresh param is true.
if ( true === $force_refresh ) {
add_action( 'wp_feed_options', array( __CLASS__, 'reset_simplepie_cache' ) );
}
// Add action: detect the podcast feed from the provided feed URL.
add_action( 'wp_feed_options', array( __CLASS__, 'set_podcast_locator' ) );
$cache_timeout_filter_added = false;
if ( $this->cache_timeout !== null ) {
// If we have a custom cache timeout, apply the custom timeout value.
add_filter( 'wp_feed_cache_transient_lifetime', array( $this, 'filter_podcast_cache_timeout' ), 20 );
$cache_timeout_filter_added = true;
}
/**
* Allow callers to set up any desired hooks when we fetch the content for a podcast.
* The `jetpack_podcast_post_fetch` action can be used to perform cleanup.
*
* @param string $podcast_url URL for the podcast's RSS feed.
*
* @since 11.2
*/
do_action( 'jetpack_podcast_pre_fetch', $this->feed );
// Fetch the feed.
$rss = fetch_feed( $this->feed );
// Remove added actions from wp_feed_options hook.
remove_action( 'wp_feed_options', array( __CLASS__, 'set_podcast_locator' ) );
if ( true === $force_refresh ) {
remove_action( 'wp_feed_options', array( __CLASS__, 'reset_simplepie_cache' ) );
}
if ( $cache_timeout_filter_added ) {
// Remove the cache timeout filter we added.
remove_filter( 'wp_feed_cache_transient_lifetime', array( $this, 'filter_podcast_cache_timeout' ), 20 );
}
/**
* Allow callers to identify when we have completed fetching a specified podcast feed.
* This makes it possible to clean up any actions or filters that were set up using the
* `jetpack_podcast_pre_fetch` action.
*
* Note that this action runs after other hooks added by Jetpack have been removed.
*
* @param string $podcast_url URL for the podcast's RSS feed.
* @param SimplePie|WP_Error $rss Either the SimplePie RSS object or an error.
*
* @since 11.2
*/
do_action( 'jetpack_podcast_post_fetch', $this->feed, $rss );
if ( is_wp_error( $rss ) ) {
return new WP_Error( 'invalid_url', __( 'Your podcast couldn\'t be embedded. Please double check your URL.', 'jetpack' ) );
}
if ( ! $rss->get_item_quantity() ) {
return new WP_Error( 'no_tracks', __( 'Podcast audio RSS feed has no tracks.', 'jetpack' ) );
}
return $rss;
}
/**
* Filter to override the default number of seconds to cache RSS feed data for the current feed.
* Note that we don't use the feed's URL because some of the SimplePie feed caches trigger this
* filter with a feed identifier and not a URL.
*
* @param int $cache_timeout_in_seconds Number of seconds to cache the podcast feed.
*
* @return int The number of seconds to cache the podcast feed.
*/
public function filter_podcast_cache_timeout( $cache_timeout_in_seconds ) {
if ( $this->cache_timeout !== null ) {
return $this->cache_timeout;
}
return $cache_timeout_in_seconds;
}
/**
* Action handler to set our podcast specific feed locator class on the SimplePie object.
*
* @param SimplePie $feed The SimplePie object, passed by reference.
*/
public static function set_podcast_locator( &$feed ) {
if ( ! class_exists( 'Jetpack_Podcast_Feed_Locator' ) ) {
require_once JETPACK__PLUGIN_DIR . '/_inc/lib/class-jetpack-podcast-feed-locator.php';
}
$feed->set_locator_class( 'Jetpack_Podcast_Feed_Locator' );
}
/**
* Action handler to reset the SimplePie cache for the podcast feed.
*
* Note this only resets the cache for the specified url. If the feed locator finds the podcast feed
* within the markup of the that url, that feed itself may still be cached.
*
* @param SimplePie $feed The SimplePie object, passed by reference.
* @return void
*/
public static function reset_simplepie_cache( &$feed ) {
// Retrieve the cache object for a feed url. Based on:
// https://github.com/WordPress/WordPress/blob/fd1c2cb4011845ceb7244a062b09b2506082b1c9/wp-includes/class-simplepie.php#L1412.
$cache = $feed->registry->call( 'Cache', 'get_handler', array( $feed->cache_location, call_user_func( $feed->cache_name_function, $feed->feed_url ), 'spc' ) );
if ( method_exists( $cache, 'unlink' ) ) {
$cache->unlink();
}
}
/**
* Prepares Episode data to be used by the Podcast Player block.
*
* @param SimplePie_Item $episode SimplePie_Item object, representing a podcast episode.
* @return array
*/
protected function setup_tracks_callback( SimplePie_Item $episode ) {
$enclosure = $this->get_audio_enclosure( $episode );
// If the audio enclosure is empty then it is not playable.
// We therefore return an empty array for this track.
// It will be filtered out later.
if ( is_wp_error( $enclosure ) ) {
return array();
}
// If there is no link return an empty array. We will filter out later.
if ( empty( $enclosure->link ) ) {
return array();
}
$publish_date = $episode->get_gmdate( DATE_ATOM );
// Build track data.
$track = array(
'id' => wp_unique_id( 'podcast-track-' ),
'link' => esc_url( $episode->get_link() ),
'src' => esc_url( $enclosure->link ),
'type' => esc_attr( $enclosure->type ),
'description' => $this->get_plain_text( $episode->get_description() ),
'description_html' => $this->get_html_text( $episode->get_description() ),
'title' => $this->get_plain_text( $episode->get_title() ),
'image' => esc_url( $this->get_episode_image_url( $episode ) ),
'guid' => $this->get_plain_text( $episode->get_id() ),
'publish_date' => $publish_date ? $publish_date : null,
);
if ( empty( $track['title'] ) ) {
$track['title'] = esc_html__( '(no title)', 'jetpack' );
}
if ( ! empty( $enclosure->duration ) ) {
$track['duration'] = esc_html( $this->format_track_duration( $enclosure->duration ) );
}
return $track;
}
/**
* Retrieves an episode's image URL, if it's available.
*
* @param SimplePie_Item $episode SimplePie_Item object, representing a podcast episode.
* @param string $itunes_ns The itunes namespace, defaulted to the standard 1.0 version.
* @return string|null The image URL or null if not found.
*/
protected function get_episode_image_url( SimplePie_Item $episode, $itunes_ns = 'http://www.itunes.com/dtds/podcast-1.0.dtd' ) {
$image = $episode->get_item_tags( $itunes_ns, 'image' );
if ( isset( $image[0]['attribs']['']['href'] ) ) {
return $image[0]['attribs']['']['href'];
}
return null;
}
/**
* Retrieves an audio enclosure.
*
* @param SimplePie_Item $episode SimplePie_Item object, representing a podcast episode.
* @return SimplePie_Enclosure|null
*/
protected function get_audio_enclosure( SimplePie_Item $episode ) {
foreach ( (array) $episode->get_enclosures() as $enclosure ) {
if ( 0 === strpos( $enclosure->type, 'audio/' ) ) {
return $enclosure;
}
}
return new WP_Error( 'invalid_audio', __( 'Podcast audio is an invalid type.', 'jetpack' ) );
}
/**
* Returns the track duration as a formatted string.
*
* @param number $duration of the track in seconds.
* @return string
*/
protected function format_track_duration( $duration ) {
$format = $duration > HOUR_IN_SECONDS ? 'H:i:s' : 'i:s';
return date_i18n( $format, $duration );
}
/**
* Gets podcast player data schema.
*
* Useful for json schema in REST API endpoints.
*
* @return array Player data json schema.
*/
public static function get_player_data_schema() {
return array(
'$schema' => 'http://json-schema.org/draft-04/schema#',
'title' => 'jetpack-podcast-player-data',
'type' => 'object',
'properties' => array(
'title' => array(
'description' => __( 'The title of the podcast.', 'jetpack' ),
'type' => 'string',
),
'link' => array(
'description' => __( 'The URL of the podcast website.', 'jetpack' ),
'type' => 'string',
'format' => 'uri',
),
'cover' => array(
'description' => __( 'The URL of the podcast cover image.', 'jetpack' ),
'type' => 'string',
'format' => 'uri',
),
'tracks' => self::get_tracks_schema(),
'options' => self::get_options_schema(),
),
);
}
/**
* Gets tracks data schema.
*
* Useful for json schema in REST API endpoints.
*
* @return array Tracks json schema.
*/
public static function get_tracks_schema() {
return array(
'description' => __( 'Latest episodes of the podcast.', 'jetpack' ),
'type' => 'array',
'items' => array(
'type' => 'object',
'properties' => array(
'id' => array(
'description' => __( 'The episode id. Generated per request, not globally unique.', 'jetpack' ),
'type' => 'string',
),
'link' => array(
'description' => __( 'The external link for the episode.', 'jetpack' ),
'type' => 'string',
'format' => 'uri',
),
'src' => array(
'description' => __( 'The audio file URL of the episode.', 'jetpack' ),
'type' => 'string',
'format' => 'uri',
),
'type' => array(
'description' => __( 'The mime type of the episode.', 'jetpack' ),
'type' => 'string',
),
'description' => array(
'description' => __( 'The episode description, in plaintext.', 'jetpack' ),
'type' => 'string',
),
'description_html' => array(
'description' => __( 'The episode description with allowed html tags.', 'jetpack' ),
'type' => 'string',
),
'title' => array(
'description' => __( 'The episode title.', 'jetpack' ),
'type' => 'string',
),
'publish_date' => array(
'description' => __( 'The UTC publish date and time of the episode', 'jetpack' ),
'type' => 'string',
'format' => 'date-time',
),
),
),
);
}
/**
* Gets the episode options schema.
*
* Useful for json schema in REST API endpoints.
*
* @return array Tracks json schema.
*/
public static function get_options_schema() {
return array(
'description' => __( 'The options that will be displayed in the episode selection UI', 'jetpack' ),
'type' => 'array',
'items' => array(
'type' => 'object',
'properties' => array(
'label' => array(
'description' => __( 'The display label of the option, the episode title.', 'jetpack' ),
'type' => 'string',
),
'value' => array(
'description' => __( 'The value used for that option, the episode GUID', 'jetpack' ),
'type' => 'string',
),
),
),
);
}
}