Aucune description

class-jetpack-podcast-helper.php 16KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515
  1. <?php
  2. /**
  3. * Helper to massage Podcast data to be used in the Podcast block.
  4. *
  5. * @package automattic/jetpack
  6. */
  7. /**
  8. * Class Jetpack_Podcast_Helper
  9. */
  10. class Jetpack_Podcast_Helper {
  11. /**
  12. * The RSS feed of the podcast.
  13. *
  14. * @var string
  15. */
  16. protected $feed = null;
  17. /**
  18. * Initialize class.
  19. *
  20. * @param string $feed The RSS feed of the podcast.
  21. */
  22. public function __construct( $feed ) {
  23. $this->feed = esc_url_raw( $feed );
  24. }
  25. /**
  26. * Gets podcast data formatted to be used by the Podcast Player block in both server-side
  27. * block rendering and in API `WPCOM_REST_API_V2_Endpoint_Podcast_Player`.
  28. *
  29. * The result is cached for one hour.
  30. *
  31. * @param array $args {
  32. * Optional array of arguments.
  33. * @type string|int $guid The ID of a specific episode to return rather than a list.
  34. * }
  35. *
  36. * @return array|WP_Error The player data or a error object.
  37. */
  38. public function get_player_data( $args = array() ) {
  39. $guids = isset( $args['guids'] ) && $args['guids'] ? $args['guids'] : array();
  40. $episode_options = isset( $args['episode-options'] ) && $args['episode-options'];
  41. // Try loading data from the cache.
  42. $transient_key = 'jetpack_podcast_' . md5( $this->feed . implode( ',', $guids ) . "-$episode_options" );
  43. $player_data = get_transient( $transient_key );
  44. // Fetch data if we don't have any cached.
  45. if ( false === $player_data || ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) {
  46. // Load feed.
  47. $rss = $this->load_feed();
  48. if ( is_wp_error( $rss ) ) {
  49. return $rss;
  50. }
  51. // Get a list of episodes by guid or all tracks in feed.
  52. if ( count( $guids ) ) {
  53. $tracks = array_map( array( $this, 'get_track_data' ), $guids );
  54. $tracks = array_filter(
  55. $tracks,
  56. function ( $track ) {
  57. return ! is_wp_error( $track );
  58. }
  59. );
  60. } else {
  61. $tracks = $this->get_track_list();
  62. }
  63. if ( empty( $tracks ) ) {
  64. 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' ) );
  65. }
  66. // Get podcast meta.
  67. $title = $rss->get_title();
  68. $title = $this->get_plain_text( $title );
  69. $description = $rss->get_description();
  70. $description = $this->get_plain_text( $description );
  71. $cover = $rss->get_image_url();
  72. $cover = ! empty( $cover ) ? esc_url( $cover ) : null;
  73. $link = $rss->get_link();
  74. $link = ! empty( $link ) ? esc_url( $link ) : null;
  75. $player_data = array(
  76. 'title' => $title,
  77. 'description' => $description,
  78. 'link' => $link,
  79. 'cover' => $cover,
  80. 'tracks' => $tracks,
  81. );
  82. if ( $episode_options ) {
  83. $player_data['options'] = array();
  84. foreach ( $rss->get_items() as $episode ) {
  85. $enclosure = $this->get_audio_enclosure( $episode );
  86. // If the episode doesn't have playable audio, then don't include it.
  87. if ( is_wp_error( $enclosure ) ) {
  88. continue;
  89. }
  90. $player_data['options'][] = array(
  91. 'label' => $this->get_plain_text( $episode->get_title() ),
  92. 'value' => $episode->get_id(),
  93. );
  94. }
  95. }
  96. // Cache for 1 hour.
  97. set_transient( $transient_key, $player_data, HOUR_IN_SECONDS );
  98. }
  99. return $player_data;
  100. }
  101. /**
  102. * Gets a specific track from the supplied feed URL.
  103. *
  104. * @param string $guid The GUID of the track.
  105. * @param boolean $force_refresh Clear the feed cache.
  106. * @return array|WP_Error The track object or an error object.
  107. */
  108. public function get_track_data( $guid, $force_refresh = false ) {
  109. // Get the cache key.
  110. $transient_key = 'jetpack_podcast_' . md5( "$this->feed::$guid" );
  111. // Clear the cache if force_refresh param is true.
  112. if ( true === $force_refresh ) {
  113. delete_transient( $transient_key );
  114. }
  115. // Try loading track data from the cache.
  116. $track_data = get_transient( $transient_key );
  117. // Fetch data if we don't have any cached.
  118. if ( false === $track_data || ( defined( 'WP_DEBUG' ) && WP_DEBUG ) ) {
  119. // Load feed.
  120. $rss = $this->load_feed( $force_refresh );
  121. if ( is_wp_error( $rss ) ) {
  122. return $rss;
  123. }
  124. // Loop over all tracks to find the one.
  125. foreach ( $rss->get_items() as $track ) {
  126. if ( $guid === $track->get_id() ) {
  127. $track_data = $this->setup_tracks_callback( $track );
  128. break;
  129. }
  130. }
  131. if ( false === $track_data ) {
  132. return new WP_Error( 'no_track', __( 'The track was not found.', 'jetpack' ) );
  133. }
  134. // Cache for 1 hour.
  135. set_transient( $transient_key, $track_data, HOUR_IN_SECONDS );
  136. }
  137. return $track_data;
  138. }
  139. /**
  140. * Gets a list of tracks for the supplied RSS feed.
  141. *
  142. * @return array|WP_Error The feed's tracks or a error object.
  143. */
  144. public function get_track_list() {
  145. $rss = $this->load_feed();
  146. if ( is_wp_error( $rss ) ) {
  147. return $rss;
  148. }
  149. /**
  150. * Allow requesting a specific number of tracks from SimplePie's `get_items` call.
  151. * The default number of tracks is ten.
  152. *
  153. * @since 9.5.0
  154. *
  155. * @param int $number Number of tracks fetched. Default is 10.
  156. * @param object $rss The SimplePie object built from core's `fetch_feed` call.
  157. */
  158. $tracks_quantity = apply_filters( 'jetpack_podcast_helper_list_quantity', 10, $rss );
  159. // Process the requested number of items from our feed.
  160. $track_list = array_map( array( __CLASS__, 'setup_tracks_callback' ), $rss->get_items( 0, $tracks_quantity ) );
  161. // Filter out any tracks that are empty.
  162. // Reset the array indicies.
  163. return array_values( array_filter( $track_list ) );
  164. }
  165. /**
  166. * Formats string as pure plaintext, with no HTML tags or entities present.
  167. * This is ready to be used in React, innerText but needs to be escaped
  168. * using standard `esc_html` when generating markup on server.
  169. *
  170. * @param string $str Input string.
  171. * @return string Plain text string.
  172. */
  173. protected function get_plain_text( $str ) {
  174. return $this->sanitize_and_decode_text( $str, true );
  175. }
  176. /**
  177. * Formats strings as safe HTML.
  178. *
  179. * @param string $str Input string.
  180. * @return string HTML text string safe for post_content.
  181. */
  182. protected function get_html_text( $str ) {
  183. return $this->sanitize_and_decode_text( $str, false );
  184. }
  185. /**
  186. * Strip unallowed html tags and decode entities.
  187. *
  188. * @param string $str Input string.
  189. * @param boolean $strip_all_tags Strip all tags, otherwise allow post_content safe tags.
  190. * @return string Sanitized and decoded text.
  191. */
  192. protected function sanitize_and_decode_text( $str, $strip_all_tags = true ) {
  193. // Trim string and return if empty.
  194. $str = trim( (string) $str );
  195. if ( empty( $str ) ) {
  196. return '';
  197. }
  198. if ( $strip_all_tags ) {
  199. // Make sure there are no tags.
  200. $str = wp_strip_all_tags( $str );
  201. } else {
  202. $str = wp_kses_post( $str );
  203. }
  204. // Replace all entities with their characters, including all types of quotes.
  205. $str = html_entity_decode( $str, ENT_QUOTES );
  206. return $str;
  207. }
  208. /**
  209. * Loads an RSS feed using `fetch_feed`.
  210. *
  211. * @param boolean $force_refresh Clear the feed cache.
  212. * @return SimplePie|WP_Error The RSS object or error.
  213. */
  214. public function load_feed( $force_refresh = false ) {
  215. // Add action: clear the SimplePie Cache if $force_refresh param is true.
  216. if ( true === $force_refresh ) {
  217. add_action( 'wp_feed_options', array( __CLASS__, 'reset_simplepie_cache' ) );
  218. }
  219. // Add action: detect the podcast feed from the provided feed URL.
  220. add_action( 'wp_feed_options', array( __CLASS__, 'set_podcast_locator' ) );
  221. // Fetch the feed.
  222. $rss = fetch_feed( $this->feed );
  223. // Remove added actions from wp_feed_options hook.
  224. remove_action( 'wp_feed_options', array( __CLASS__, 'set_podcast_locator' ) );
  225. if ( true === $force_refresh ) {
  226. remove_action( 'wp_feed_options', array( __CLASS__, 'reset_simplepie_cache' ) );
  227. }
  228. if ( is_wp_error( $rss ) ) {
  229. return new WP_Error( 'invalid_url', __( 'Your podcast couldn\'t be embedded. Please double check your URL.', 'jetpack' ) );
  230. }
  231. if ( ! $rss->get_item_quantity() ) {
  232. return new WP_Error( 'no_tracks', __( 'Podcast audio RSS feed has no tracks.', 'jetpack' ) );
  233. }
  234. return $rss;
  235. }
  236. /**
  237. * Action handler to set our podcast specific feed locator class on the SimplePie object.
  238. *
  239. * @param SimplePie $feed The SimplePie object, passed by reference.
  240. */
  241. public static function set_podcast_locator( &$feed ) {
  242. if ( ! class_exists( 'Jetpack_Podcast_Feed_Locator' ) ) {
  243. jetpack_require_lib( 'class-jetpack-podcast-feed-locator' );
  244. }
  245. $feed->set_locator_class( 'Jetpack_Podcast_Feed_Locator' );
  246. }
  247. /**
  248. * Action handler to reset the SimplePie cache for the podcast feed.
  249. *
  250. * Note this only resets the cache for the specified url. If the feed locator finds the podcast feed
  251. * within the markup of the that url, that feed itself may still be cached.
  252. *
  253. * @param SimplePie $feed The SimplePie object, passed by reference.
  254. * @return void
  255. */
  256. public static function reset_simplepie_cache( &$feed ) {
  257. // Retrieve the cache object for a feed url. Based on:
  258. // https://github.com/WordPress/WordPress/blob/fd1c2cb4011845ceb7244a062b09b2506082b1c9/wp-includes/class-simplepie.php#L1412.
  259. $cache = $feed->registry->call( 'Cache', 'get_handler', array( $feed->cache_location, call_user_func( $feed->cache_name_function, $feed->feed_url ), 'spc' ) );
  260. if ( method_exists( $cache, 'unlink' ) ) {
  261. $cache->unlink();
  262. }
  263. }
  264. /**
  265. * Prepares Episode data to be used by the Podcast Player block.
  266. *
  267. * @param SimplePie_Item $episode SimplePie_Item object, representing a podcast episode.
  268. * @return array
  269. */
  270. protected function setup_tracks_callback( SimplePie_Item $episode ) {
  271. $enclosure = $this->get_audio_enclosure( $episode );
  272. // If the audio enclosure is empty then it is not playable.
  273. // We therefore return an empty array for this track.
  274. // It will be filtered out later.
  275. if ( is_wp_error( $enclosure ) ) {
  276. return array();
  277. }
  278. // If there is no link return an empty array. We will filter out later.
  279. if ( empty( $enclosure->link ) ) {
  280. return array();
  281. }
  282. $publish_date = $episode->get_gmdate( DATE_ATOM );
  283. // Build track data.
  284. $track = array(
  285. 'id' => wp_unique_id( 'podcast-track-' ),
  286. 'link' => esc_url( $episode->get_link() ),
  287. 'src' => esc_url( $enclosure->link ),
  288. 'type' => esc_attr( $enclosure->type ),
  289. 'description' => $this->get_plain_text( $episode->get_description() ),
  290. 'description_html' => $this->get_html_text( $episode->get_description() ),
  291. 'title' => $this->get_plain_text( $episode->get_title() ),
  292. 'image' => esc_url( $this->get_episode_image_url( $episode ) ),
  293. 'guid' => $this->get_plain_text( $episode->get_id() ),
  294. 'publish_date' => $publish_date ? $publish_date : null,
  295. );
  296. if ( empty( $track['title'] ) ) {
  297. $track['title'] = esc_html__( '(no title)', 'jetpack' );
  298. }
  299. if ( ! empty( $enclosure->duration ) ) {
  300. $track['duration'] = esc_html( $this->format_track_duration( $enclosure->duration ) );
  301. }
  302. return $track;
  303. }
  304. /**
  305. * Retrieves an episode's image URL, if it's available.
  306. *
  307. * @param SimplePie_Item $episode SimplePie_Item object, representing a podcast episode.
  308. * @param string $itunes_ns The itunes namespace, defaulted to the standard 1.0 version.
  309. * @return string|null The image URL or null if not found.
  310. */
  311. protected function get_episode_image_url( SimplePie_Item $episode, $itunes_ns = 'http://www.itunes.com/dtds/podcast-1.0.dtd' ) {
  312. $image = $episode->get_item_tags( $itunes_ns, 'image' );
  313. if ( isset( $image[0]['attribs']['']['href'] ) ) {
  314. return $image[0]['attribs']['']['href'];
  315. }
  316. return null;
  317. }
  318. /**
  319. * Retrieves an audio enclosure.
  320. *
  321. * @param SimplePie_Item $episode SimplePie_Item object, representing a podcast episode.
  322. * @return SimplePie_Enclosure|null
  323. */
  324. protected function get_audio_enclosure( SimplePie_Item $episode ) {
  325. foreach ( (array) $episode->get_enclosures() as $enclosure ) {
  326. if ( 0 === strpos( $enclosure->type, 'audio/' ) ) {
  327. return $enclosure;
  328. }
  329. }
  330. return new WP_Error( 'invalid_audio', __( 'Podcast audio is an invalid type.', 'jetpack' ) );
  331. }
  332. /**
  333. * Returns the track duration as a formatted string.
  334. *
  335. * @param number $duration of the track in seconds.
  336. * @return string
  337. */
  338. protected function format_track_duration( $duration ) {
  339. $format = $duration > HOUR_IN_SECONDS ? 'H:i:s' : 'i:s';
  340. return date_i18n( $format, $duration );
  341. }
  342. /**
  343. * Gets podcast player data schema.
  344. *
  345. * Useful for json schema in REST API endpoints.
  346. *
  347. * @return array Player data json schema.
  348. */
  349. public static function get_player_data_schema() {
  350. return array(
  351. '$schema' => 'http://json-schema.org/draft-04/schema#',
  352. 'title' => 'jetpack-podcast-player-data',
  353. 'type' => 'object',
  354. 'properties' => array(
  355. 'title' => array(
  356. 'description' => __( 'The title of the podcast.', 'jetpack' ),
  357. 'type' => 'string',
  358. ),
  359. 'link' => array(
  360. 'description' => __( 'The URL of the podcast website.', 'jetpack' ),
  361. 'type' => 'string',
  362. 'format' => 'uri',
  363. ),
  364. 'cover' => array(
  365. 'description' => __( 'The URL of the podcast cover image.', 'jetpack' ),
  366. 'type' => 'string',
  367. 'format' => 'uri',
  368. ),
  369. 'tracks' => self::get_tracks_schema(),
  370. 'options' => self::get_options_schema(),
  371. ),
  372. );
  373. }
  374. /**
  375. * Gets tracks data schema.
  376. *
  377. * Useful for json schema in REST API endpoints.
  378. *
  379. * @return array Tracks json schema.
  380. */
  381. public static function get_tracks_schema() {
  382. return array(
  383. 'description' => __( 'Latest episodes of the podcast.', 'jetpack' ),
  384. 'type' => 'array',
  385. 'items' => array(
  386. 'type' => 'object',
  387. 'properties' => array(
  388. 'id' => array(
  389. 'description' => __( 'The episode id. Generated per request, not globally unique.', 'jetpack' ),
  390. 'type' => 'string',
  391. ),
  392. 'link' => array(
  393. 'description' => __( 'The external link for the episode.', 'jetpack' ),
  394. 'type' => 'string',
  395. 'format' => 'uri',
  396. ),
  397. 'src' => array(
  398. 'description' => __( 'The audio file URL of the episode.', 'jetpack' ),
  399. 'type' => 'string',
  400. 'format' => 'uri',
  401. ),
  402. 'type' => array(
  403. 'description' => __( 'The mime type of the episode.', 'jetpack' ),
  404. 'type' => 'string',
  405. ),
  406. 'description' => array(
  407. 'description' => __( 'The episode description, in plaintext.', 'jetpack' ),
  408. 'type' => 'string',
  409. ),
  410. 'description_html' => array(
  411. 'description' => __( 'The episode description with allowed html tags.', 'jetpack' ),
  412. 'type' => 'string',
  413. ),
  414. 'title' => array(
  415. 'description' => __( 'The episode title.', 'jetpack' ),
  416. 'type' => 'string',
  417. ),
  418. 'publish_date' => array(
  419. 'description' => __( 'The UTC publish date and time of the episode', 'jetpack' ),
  420. 'type' => 'string',
  421. 'format' => 'date-time',
  422. ),
  423. ),
  424. ),
  425. );
  426. }
  427. /**
  428. * Gets the episode options schema.
  429. *
  430. * Useful for json schema in REST API endpoints.
  431. *
  432. * @return array Tracks json schema.
  433. */
  434. public static function get_options_schema() {
  435. return array(
  436. 'description' => __( 'The options that will be displayed in the episode selection UI', 'jetpack' ),
  437. 'type' => 'array',
  438. 'items' => array(
  439. 'type' => 'object',
  440. 'properties' => array(
  441. 'label' => array(
  442. 'description' => __( 'The display label of the option, the episode title.', 'jetpack' ),
  443. 'type' => 'string',
  444. ),
  445. 'value' => array(
  446. 'description' => __( 'The value used for that option, the episode GUID', 'jetpack' ),
  447. 'type' => 'string',
  448. ),
  449. ),
  450. ),
  451. );
  452. }
  453. }