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
jetpack / jetpack_vendor / automattic / jetpack-podcast / src / feed / class-customize-feed.php

class-customize-feed.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3, at jetpack_vendor/automattic/jetpack-podcast/src/feed/class-customize-feed.php

656 lines 24.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Adds podcast tags + tracked enclosure URLs to the RSS feed for the
4 * configured podcast category.
5 *
6 * @package automattic/jetpack-podcast
7 */
8
9 declare( strict_types = 1 );
10
11 namespace Automattic\Jetpack\Podcast\Feed;
12
13 use Automattic\Jetpack\Connection\Manager as Connection_Manager;
14 use Automattic\Jetpack\Podcast\Settings;
15 use WP_Post;
16
17 /**
18 * Hooks into RSS2 rendering when the current request is the podcast category
19 * feed, adding `<itunes:*>` + `<podcast:*>` tags at channel and item level
20 * and rewriting `<enclosure>` URLs through the WPCOM stats endpoint.
21 */
22 class Customize_Feed {
23
24 /**
25 * Whether `init()` has wired its hooks.
26 *
27 * @var bool
28 */
29 private static $registered = false;
30
31 /**
32 * Enclosure URLs already emitted in the current feed render, keyed by post
33 * ID then URL. Reset on `rss2_head` so each feed starts clean — see
34 * {@see self::reset_render_state()}.
35 *
36 * @var array<int, array<string, true>>
37 */
38 private static $seen_enclosures = array();
39
40 /**
41 * The `<description>` most recently rendered, as `[ post ID, value ]`, for
42 * {@see self::output_item_tags()} to reuse. One slot: the template emits
43 * `<description>` immediately before `rss2_item` fires for the same item.
44 * Reset per render — see {@see self::reset_render_state()} — so a template
45 * that skips `<description>` can't match a previous render's post ID.
46 *
47 * @var array{0: int, 1: string}
48 */
49 private static $item_summary = array( 0, '' );
50
51 /**
52 * Wire the late-binding `wp` action that decides whether to register the
53 * feed-modification hooks for this request. Idempotent.
54 */
55 public static function init() {
56 if ( self::$registered ) {
57 return;
58 }
59 self::$registered = true;
60
61 add_action( 'wp', array( __CLASS__, 'maybe_register_feed_hooks' ) );
62
63 // These hooks fire during query execution — before the `wp` action — so
64 // they're registered up-front and self-gated to the podcast feed query,
65 // rather than wired conditionally in `maybe_register_feed_hooks`.
66 //
67 // `posts_where` constrains the SQL itself so LIMIT/OFFSET paginate over
68 // episodes that actually have an enclosure; `the_posts` is a cheap final
69 // guard for the rare row the SQL constraint can't reach.
70 //
71 // `pre_get_posts` runs last so the gate reads the category every other
72 // callback has finished rewriting — and so `get_queried_object()` doesn't
73 // memoize a term ahead of them, which `posts_where` would then inherit.
74 add_action( 'pre_get_posts', array( __CLASS__, 'apply_feed_limit' ), PHP_INT_MAX );
75 add_filter( 'posts_where', array( __CLASS__, 'constrain_feed_query' ), 10, 2 );
76 add_filter( 'the_posts', array( __CLASS__, 'filter_posts_with_enclosure' ), 10, 2 );
77 }
78
79 /**
80 * Register the RSS2 hooks if this request is the configured podcast feed.
81 * Also fires `Feed_Detection` while we're here — same gating, no need to
82 * walk the post query twice.
83 */
84 public static function maybe_register_feed_hooks() {
85 if ( ! is_feed() ) {
86 return;
87 }
88 $category_id = self::resolve_category_id();
89 if ( 0 === $category_id || ! is_category( $category_id ) ) {
90 return;
91 }
92
93 // Strip channel-level tags that conflict with the iTunes-compliant
94 // header: blavatar / site-icon `<image>` duplicates `<itunes:image>`,
95 // and `<cloud …/>` from rsscloud isn't part of the podcast spec.
96 remove_action( 'rss2_head', 'rss2_blavatar' );
97 remove_action( 'rss2_head', 'rss2_site_icon' );
98 remove_action( 'rss2_head', 'rsscloud_add_rss_cloud_element' );
99
100 add_action( 'rss2_ns', array( __CLASS__, 'output_namespaces' ) );
101 add_filter( 'wp_title_rss', array( __CLASS__, 'feed_title' ) );
102 add_filter( 'bloginfo_rss', array( __CLASS__, 'feed_description' ), 10, 2 );
103 add_action( 'rss2_head', array( __CLASS__, 'reset_render_state' ), 0 );
104 add_action( 'rss2_head', array( __CLASS__, 'output_channel_tags' ) );
105 add_action( 'rss2_item', array( __CLASS__, 'output_item_tags' ) );
106 add_filter( 'rss_enclosure', array( __CLASS__, 'rewrite_enclosure' ) );
107 // Last, so the captured value is what `<description>` actually printed —
108 // a filter above priority 10 would otherwise leave us caching an
109 // intermediate string and `<itunes:summary>` disagreeing with it.
110 add_filter( 'the_excerpt_rss', array( __CLASS__, 'capture_item_summary' ), PHP_INT_MAX );
111
112 add_filter( 'option_rss_use_excerpt', '__return_false' );
113 // Request-scoped to the feed: only the queried episodes render here, so
114 // this never touches block output outside the podcast feed response.
115 add_filter( 'pre_render_block', array( __CLASS__, 'skip_block_in_feed' ), 10, 2 );
116 add_filter( 'comments_open', '__return_false' );
117 add_filter( 'get_comments_number', '__return_zero' );
118 add_filter( 'the_category_rss', '__return_empty_string' );
119 remove_action( 'rss2_item', 'mrss_item', 10 );
120 remove_action( 'rss2_item', 'mrss_news_item' );
121
122 Feed_Detection::detect_and_record();
123 }
124
125 /**
126 * Add iTunes and Podcasting 2.0 XML namespaces to the `<rss>` open tag.
127 */
128 public static function output_namespaces() {
129 echo "\n\t" . 'xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"' . "\n";
130 echo "\t" . 'xmlns:podcast="https://podcastindex.org/namespace/1.0"' . "\n";
131 }
132
133 /**
134 * Override the feed title with `podcasting_title`, falling back to
135 * `Blog Name » Category Name`.
136 *
137 * @param string $title Existing title.
138 * @return string
139 */
140 public static function feed_title( $title ) {
141 $override = (string) get_option( 'podcasting_title', '' );
142 if ( '' !== $override ) {
143 return esc_xml( $override );
144 }
145
146 $category = get_category( self::resolve_category_id() );
147 if ( $category && ! is_wp_error( $category ) ) {
148 return esc_xml( get_bloginfo( 'name' ) ) . ' &#187; ' . esc_xml( $category->name );
149 }
150 return esc_xml( $title );
151 }
152
153 /**
154 * Replace the `bloginfo_rss('description')` value with `podcasting_summary`.
155 *
156 * `bloginfo_rss()` echoes the filter return value directly, so we strip and
157 * escape here — matches the channel-level `<itunes:summary>` treatment and
158 * keeps stray markup in the option from leaking into `<description>`.
159 *
160 * @param string $value Existing value.
161 * @param string $field Field being requested.
162 * @return string
163 */
164 public static function feed_description( $value, $field ) {
165 if ( 'description' !== $field ) {
166 return $value;
167 }
168 return esc_xml( wp_strip_all_tags( (string) get_option( 'podcasting_summary', '' ) ) );
169 }
170
171 /**
172 * Channel-level podcast tags (rss2_head).
173 */
174 public static function output_channel_tags() {
175 $summary = (string) get_option( 'podcasting_summary', '' );
176 if ( '' !== $summary ) {
177 echo '<itunes:summary>' . esc_xml( wp_strip_all_tags( $summary ) ) . "</itunes:summary>\n";
178 }
179
180 $author = (string) get_option( 'podcasting_talent_name', '' );
181 if ( '' !== $author ) {
182 echo '<itunes:author>' . esc_xml( wp_strip_all_tags( $author ) ) . "</itunes:author>\n";
183 }
184
185 $email = wp_strip_all_tags( (string) get_option( 'podcasting_email', '' ) );
186 if ( '' !== $email ) {
187 echo '<itunes:owner><itunes:email>' . esc_xml( $email ) . "</itunes:email></itunes:owner>\n";
188 }
189
190 $copyright = (string) get_option( 'podcasting_copyright', '' );
191 if ( '' !== $copyright ) {
192 echo '<copyright>' . esc_xml( wp_strip_all_tags( $copyright ) ) . "</copyright>\n";
193 }
194
195 /**
196 * Explicit content flag
197 */
198 echo '<itunes:explicit>' . esc_html( self::explicit_string() ) . "</itunes:explicit>\n";
199
200 $image = self::show_image_url();
201 if ( '' !== $image ) {
202 echo '<itunes:image href="' . esc_url( $image ) . '" />' . "\n";
203 }
204
205 echo self::category_tag( (string) get_option( 'podcasting_category_1', '' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped XML fragment.
206 echo self::category_tag( (string) get_option( 'podcasting_category_2', '' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped XML fragment.
207 echo self::category_tag( (string) get_option( 'podcasting_category_3', '' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped XML fragment.
208 }
209
210 /**
211 * Item-level podcast tags (rss2_item).
212 */
213 public static function output_item_tags() {
214 global $post;
215
216 if ( ! $post instanceof WP_Post ) {
217 return;
218 }
219
220 $author = get_the_author();
221 if ( '' === $author ) {
222 $author = (string) get_option( 'podcasting_talent_name', '' );
223 }
224 if ( '' !== $author ) {
225 echo '<itunes:author>' . esc_xml( wp_strip_all_tags( $author ) ) . "</itunes:author>\n";
226 }
227
228 // Reuse the string `<description>` just emitted for this item; rebuilding
229 // it costs a second `wp_trim_excerpt()` pass over the whole post body.
230 // The fallback covers a feed template that skips `<description>`.
231 $excerpt = self::$item_summary[0] === (int) $post->ID
232 ? self::$item_summary[1]
233 : (string) apply_filters( 'the_excerpt_rss', get_the_excerpt() );
234 if ( '' !== $excerpt ) {
235 echo '<itunes:summary>' . esc_xml( wp_strip_all_tags( $excerpt ) ) . "</itunes:summary>\n";
236 }
237
238 // Per-item cover art: prefer the block's `coverArt`, fall back to the
239 // post's featured image. Either way, photon-resize to 3000×3000 to
240 // honour Apple's square-cover requirement. When neither is present
241 // the channel-level `<itunes:image>` applies as default per spec.
242 $attrs = Episode_Block_Tags::get_block_attrs( $post );
243 $cover_url = isset( $attrs['coverArt']['url'] ) ? trim( (string) $attrs['coverArt']['url'] ) : '';
244 $item_image = '' !== $cover_url ? self::maybe_photon( $cover_url ) : self::episode_image_url( $post->ID );
245 if ( '' !== $item_image ) {
246 echo '<itunes:image href="' . esc_url( $item_image ) . '" />' . "\n";
247 }
248
249 // Block-driven iTunes + Podcasting 2.0 tags. Legacy audio posts
250 // without the block contribute nothing — they keep their pre-block
251 // behavior intact aside from the cover art handled above.
252 if ( ! empty( $attrs ) ) {
253 Episode_Block_Tags::render_from_attrs( $attrs );
254 }
255 }
256
257 /**
258 * Keep the episode, feed-player, and subscribe blocks out of
259 * `<content:encoded>`, leaving the surrounding show-note prose.
260 *
261 * Short-circuits on `pre_render_block` rather than blanking the output on
262 * `render_block`, which runs the callback first and throws the result away.
263 * That callback is expensive per item: the episode block resolves DNS via
264 * `wp_http_validate_url()`, the player block fetches a feed over HTTP.
265 *
266 * @param string|null $pre_render Short-circuit value; `null` renders normally.
267 * @param array $parsed_block Parsed block, including its `blockName`.
268 * @return string|null
269 */
270 public static function skip_block_in_feed( $pre_render, $parsed_block ) {
271 if ( isset( $parsed_block['blockName'] ) && in_array(
272 $parsed_block['blockName'],
273 array( 'jetpack/podcast-episode', 'jetpack/podcast-player', 'jetpack/subscriptions' ),
274 true
275 ) ) {
276 return '';
277 }
278 return $pre_render;
279 }
280
281 /**
282 * Stash the item's rendered `<description>` for `<itunes:summary>` to reuse.
283 * Pass-through — the value is never modified.
284 *
285 * @param string $excerpt Filtered item excerpt.
286 * @return string
287 */
288 public static function capture_item_summary( $excerpt ) {
289 self::$item_summary = array( (int) get_the_ID(), (string) $excerpt );
290 return $excerpt;
291 }
292
293 /**
294 * Rewrite the enclosure URL through the WPCOM stats endpoint and append
295 * `<itunes:duration>` when resolvable. Duration is looked up against the
296 * *original* attachment URL — the stats URL is synthetic.
297 *
298 * A podcast item is only valid with a single `<enclosure>`, but core
299 * `rss_enclosure()` emits one per `enclosure` post-meta row and posts
300 * routinely accumulate several (URL drift across re-uploads / CDN hosts
301 * that `do_enclose()`'s dedup treats as distinct). Rewriting keys on
302 * post ID, so those rows all collapse to the same stats URL — we track
303 * emitted URLs per item and drop repeats so the feed carries exactly one.
304 *
305 * @param string $enclosure Generated enclosure markup.
306 * @return string
307 */
308 public static function rewrite_enclosure( $enclosure ) {
309 global $post;
310
311 if ( ! preg_match( '/url="([^"]*)"/i', $enclosure, $match ) ) {
312 return $enclosure;
313 }
314
315 $original_url = $match[1];
316 $final_url = $original_url;
317 $post_obj = $post instanceof WP_Post ? $post : null;
318
319 /**
320 * Whether to rewrite the enclosure through the WPCOM stats endpoint.
321 * Token-gated feeds (notably WPCOM's `private-podcasts.php`) opt out
322 * — the stats URL is a deterministic public endpoint that would
323 * bypass any token gating on the feed itself.
324 *
325 * @param bool $enable Default true.
326 * @param WP_Post|null $post The post being rendered.
327 */
328 $enable = (bool) apply_filters( 'wpcom_podcasting_enable_play_tracking', true, $post_obj );
329
330 // Skip rewrite for externally hosted enclosures — the stats endpoint 404s anything that isn't a local attachment.
331 $attachment_id = Episode_Media_Cache::attachment_id( $original_url );
332
333 if ( null !== $post_obj && $enable && $attachment_id > 0 ) {
334 // `null` when the site isn't connected; passed through so the filter can still inject a value.
335 $default_blog_id = Connection_Manager::get_site_id( true );
336
337 /**
338 * Override the blog ID baked into the stats URL.
339 *
340 * @param int|null $blog_id Default Jetpack connection site ID, or null when unavailable.
341 * @param WP_Post $post The post being rendered.
342 */
343 $blog_id = (int) apply_filters( 'wpcom_podcasting_tracked_blog_id', $default_blog_id, $post_obj );
344
345 // Bail when we can't resolve a real blog ID — emit the original URL rather than a guaranteed-404 stats URL.
346 if ( $blog_id > 0 ) {
347 $final_url = esc_url( self::build_stats_url( $blog_id, (int) $post_obj->ID, $original_url ) );
348 $enclosure = preg_replace_callback(
349 '/url="[^"]*"/i',
350 /**
351 * Replace the matched `url="…"` attribute with the stats URL.
352 * `$matches` is required by `preg_replace_callback`'s callable
353 * signature but ignored — we always emit the same value.
354 *
355 * @param array $matches Regex matches.
356 * @return string
357 */
358 static function ( array $matches ) use ( $final_url ) {
359 unset( $matches );
360 return 'url="' . $final_url . '"';
361 },
362 $enclosure,
363 1
364 );
365 }
366 }
367
368 // Drop repeats: rows keyed per post, by final URL, so distinct enclosures
369 // survive while the duplicate stats URLs collapse to one. Registry is
370 // cleared per render — see `reset_render_state()`.
371 $post_id = null !== $post_obj ? (int) $post_obj->ID : 0;
372 if ( isset( self::$seen_enclosures[ $post_id ][ $final_url ] ) ) {
373 return '';
374 }
375 self::$seen_enclosures[ $post_id ][ $final_url ] = true;
376
377 if ( 0 === $attachment_id ) {
378 return $enclosure;
379 }
380
381 $metadata = wp_get_attachment_metadata( $attachment_id );
382 $duration = is_array( $metadata ) ? absint( $metadata['length'] ?? 0 ) : 0;
383
384 return 0 === $duration
385 ? $enclosure
386 : $enclosure . '<itunes:duration>' . $duration . "</itunes:duration>\n";
387 }
388
389 /**
390 * Clear the per-render statics. Hooked on `rss2_head` (at priority 0, before
391 * any item renders) so re-generating a feed within a single long-lived
392 * process — WP-CLI, a warm worker — starts fresh instead of dropping every
393 * enclosure as already-seen or reusing the last render's summary.
394 */
395 public static function reset_render_state() {
396 self::$seen_enclosures = array();
397 self::$item_summary = array( 0, '' );
398 }
399
400 /**
401 * Cap the podcast feed at the configured number of episodes. Core reads the
402 * `posts_per_rss` *query var* before the site option of the same name, so the
403 * podcast feed gets its own length and every other feed keeps the site's.
404 *
405 * @param \WP_Query $query Query about to run.
406 */
407 public static function apply_feed_limit( $query ) {
408 if ( ! self::is_podcast_feed_query( $query ) ) {
409 return;
410 }
411
412 $query->set( 'posts_per_rss', Settings::feed_limit() );
413 }
414
415 /**
416 * Constrain the podcast feed's main query to episodes that carry an
417 * `enclosure` meta row, so the SQL `LIMIT`/`OFFSET` paginate over valid
418 * episodes only. Without this the enclosure filter runs on `the_posts` —
419 * after pagination — so a nominal ten-item page could come back short or
420 * empty while older valid episodes sit stranded on later pages.
421 *
422 * A correlated `EXISTS` subquery (semi-join) is used rather than a
423 * `meta_query` clause on purpose: episodes routinely accumulate several
424 * `enclosure` meta rows (see {@see self::rewrite_enclosure()}), and a
425 * single-clause `meta_query` INNER JOIN would multiply those into duplicate
426 * posts, breaking the `LIMIT` count all over again.
427 *
428 * @param string $where The `WHERE` clause of the query.
429 * @param \WP_Query $query Query about to run.
430 * @return string
431 */
432 public static function constrain_feed_query( $where, $query ) {
433 if ( ! self::is_podcast_feed_query( $query ) ) {
434 return $where;
435 }
436
437 global $wpdb;
438
439 // Table names come from `$wpdb`; `meta_key` runs through `prepare()`.
440 $where .= $wpdb->prepare(
441 " AND EXISTS ( SELECT 1 FROM {$wpdb->postmeta} WHERE {$wpdb->postmeta}.post_id = {$wpdb->posts}.ID AND {$wpdb->postmeta}.meta_key = %s )",
442 'enclosure'
443 );
444
445 return $where;
446 }
447
448 /**
449 * A podcast item without an enclosure is invalid per Apple's spec and can
450 * take down the whole submission. The `enclosure` post meta is what
451 * `rss_enclosure()` reads, so it's the authoritative signal here too.
452 *
453 * The SQL constraint in {@see self::constrain_feed_query()} already excludes
454 * these at query time; this stays as a cheap final guard.
455 *
456 * Doubles as the warm-up point for the render: this is the first hook that
457 * sees the whole page of episodes, so {@see Episode_Media_Cache::prime()}
458 * resolves their media here rather than leaving it to per-item lookups.
459 *
460 * @param WP_Post[] $posts Posts about to be looped over.
461 * @param \WP_Query $query Query that produced them.
462 * @return WP_Post[]
463 */
464 public static function filter_posts_with_enclosure( $posts, $query ) {
465 if ( ! self::is_podcast_feed_query( $query ) ) {
466 return $posts;
467 }
468
469 Episode_Media_Cache::prime( $posts );
470
471 return array_values(
472 array_filter(
473 $posts,
474 static function ( $post ) {
475 return $post instanceof WP_Post
476 && ! empty( get_post_meta( $post->ID, 'enclosure', false ) );
477 }
478 )
479 );
480 }
481
482 /**
483 * Whether `$query` is the main podcast category feed query — the shared gate
484 * for the two query-time hooks ({@see self::constrain_feed_query()} and
485 * {@see self::filter_posts_with_enclosure()}), which fire before the `wp`
486 * action and so can't lean on `maybe_register_feed_hooks()`.
487 *
488 * @param \WP_Query $query Query to inspect.
489 * @return bool
490 */
491 private static function is_podcast_feed_query( $query ): bool {
492 if ( ! $query->is_main_query() || ! $query->is_feed() || ! $query->is_category() ) {
493 return false;
494 }
495 $category_id = self::resolve_category_id();
496 if ( 0 === $category_id ) {
497 return false;
498 }
499 $queried = $query->get_queried_object();
500 return $queried && isset( $queried->term_id ) && (int) $queried->term_id === $category_id;
501 }
502
503 /**
504 * Stored explicit value, normalized to the `'true'`/`'false'` strings the
505 * iTunes spec requires. Reuses `Settings::sanitize_explicit`
506 * so legacy `'yes'`/`'no'`/`'clean'` and modern boolean storage both work.
507 *
508 * @return string
509 */
510 public static function explicit_string(): string {
511 return Settings::sanitize_explicit( get_option( 'podcasting_explicit', false ) ) ? 'true' : 'false';
512 }
513
514 /**
515 * Show-level cover image URL — `Settings::raw_show_image_url()` routed
516 * through Photon at 3000×3000 when available.
517 *
518 * @return string
519 */
520 private static function show_image_url(): string {
521 $url = Settings::raw_show_image_url();
522 return '' === $url ? '' : self::maybe_photon( $url );
523 }
524
525 /**
526 * Build the WPCOM stats URL for a given episode. The endpoint redirects
527 * to the audio file after recording the play — the package never serves
528 * it, only points at it. Audio extensions outside the recognized set
529 * fall back to `mp3` to keep the URL shape uniform (matches the Podtrac
530 * / Megaphone / Art19 convention).
531 *
532 * @param int $blog_id WPCOM blog ID (Atomic should override via the
533 * `wpcom_podcasting_tracked_blog_id` filter).
534 * @param int $post_id Episode post ID.
535 * @param string $original_url Original enclosure URL — extension is pulled from here.
536 * @return string
537 */
538 private static function build_stats_url( int $blog_id, int $post_id, string $original_url ): string {
539 $path = (string) wp_parse_url( $original_url, PHP_URL_PATH );
540 $ext = (string) preg_replace( '/[^a-z0-9]/', '', strtolower( (string) pathinfo( $path, PATHINFO_EXTENSION ) ) );
541 if ( ! in_array( $ext, array( 'mp3', 'm4a', 'm4b', 'aac', 'ogg', 'oga', 'opus', 'wav', 'flac', 'mp4', 'm4v', 'mov' ), true ) ) {
542 $ext = 'mp3';
543 }
544 return sprintf(
545 'https://public-api.wordpress.com/wpcom/v2/sites/%d/podcast-play/%d.%s',
546 $blog_id,
547 $post_id,
548 $ext
549 );
550 }
551
552 /**
553 * Resolve the configured podcast category ID. Prefers the numeric
554 * `podcasting_category_id`, falling back to a slug lookup against the
555 * legacy `podcasting_archive` option — older sites pre-date numeric
556 * storage and only have the slug. Returns 0 when neither resolves.
557 *
558 * A numeric ID whose term was deleted means "not configured" — the slug
559 * is not consulted in that case.
560 *
561 * @return int
562 */
563 public static function resolve_category_id(): int {
564 $category_id = (int) get_option( 'podcasting_category_id', 0 );
565 if ( $category_id > 0 ) {
566 $category = get_category( $category_id );
567 return ( $category && ! is_wp_error( $category ) && isset( $category->term_id ) ) ? (int) $category->term_id : 0;
568 }
569
570 $slug = (string) get_option( 'podcasting_archive', '' );
571 if ( '' === $slug ) {
572 return 0;
573 }
574
575 $term = get_term_by( 'slug', $slug, 'category' );
576 return ( $term && ! is_wp_error( $term ) && isset( $term->term_id ) ) ? (int) $term->term_id : 0;
577 }
578
579 /**
580 * Episode-level image URL — the post's featured image, Photon-resized,
581 * or `''` when no featured image is set. Used as the fallback per-item
582 * cover when the block doesn't supply its own.
583 *
584 * @param int $post_id Episode post ID.
585 * @return string
586 */
587 private static function episode_image_url( int $post_id ): string {
588 if ( ! has_post_thumbnail( $post_id ) ) {
589 return '';
590 }
591 $src = wp_get_attachment_image_src( get_post_thumbnail_id( $post_id ), 'full' );
592 if ( ! is_array( $src ) || empty( $src[0] ) ) {
593 return '';
594 }
595 return self::maybe_photon( $src[0] );
596 }
597
598 /**
599 * Route through Photon at exactly 3000×3000 so the feed always serves a
600 * square cover, regardless of the source aspect ratio. `resize` center-crops
601 * (unlike `fit`, which only constrains within the box); Apple's spec wants
602 * 1400–3000 px square art and rejects non-square covers.
603 *
604 * @param string $url Image URL.
605 * @return string
606 */
607 public static function maybe_photon( string $url ): string {
608 if ( ! function_exists( 'jetpack_photon_url' ) ) {
609 return $url;
610 }
611 // @phan-suppress-next-line PhanUndeclaredFunction -- Provided by Jetpack's Photon module at runtime; guarded by `function_exists` above.
612 return (string) jetpack_photon_url( $url, array( 'resize' => '3000,3000' ), 'https' );
613 }
614
615 /**
616 * Build a single `<itunes:category>` tag from a stored option value. The
617 * stored format is one of:
618 * - `''` (no category)
619 * - `'Foo'` → single category
620 * - `'Foo,Bar'` → category Foo with subcategory Bar
621 *
622 * Includes a back-compat translation pass for a few legacy values that were
623 * stored in non-canonical shapes before validation tightened.
624 *
625 * @param string $stored Raw option value.
626 * @return string Empty string if no category, otherwise an XML fragment.
627 */
628 public static function category_tag( string $stored ): string {
629 static $legacy_aliases = array(
630 'Education,Education' => 'Education',
631 'Education,Education Technology' => 'Education,Educational Technology',
632 'Tech News' => 'Technology,Tech News',
633 'Sports &amp; Recreation,Technology' => 'Technology',
634 'Sports &amp; Recreation,Gadgets' => 'Technology,Gadgets',
635 'Sports,Football' => 'Sports,American Football',
636 'Sports,Soccer' => 'Sports,Football (Soccer)',
637 );
638 $category = $legacy_aliases[ $stored ] ?? $stored;
639
640 if ( '' === $category ) {
641 return '';
642 }
643
644 // `ent2ncr()` normalises named HTML entities (e.g. `&nbsp;`, `&copy;`) into
645 // numeric character references so an attribute value containing them stays
646 // well-formed XML after esc_attr().
647 $splits = explode( ',', $category );
648 if ( 2 === count( $splits ) ) {
649 return '<itunes:category text="' . ent2ncr( esc_attr( $splits[0] ) ) . '">' . "\n"
650 . "\t" . '<itunes:category text="' . ent2ncr( esc_attr( $splits[1] ) ) . '" />' . "\n"
651 . "</itunes:category>\n";
652 }
653 return '<itunes:category text="' . ent2ncr( esc_attr( $category ) ) . '" />' . "\n";
654 }
655 }
656