Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
69.80% covered (warning)
69.80%
141 / 202
59.09% covered (warning)
59.09%
13 / 22
CRAP
0.00% covered (danger)
0.00%
0 / 1
Customize_Feed
69.80% covered (warning)
69.80%
141 / 202
59.09% covered (warning)
59.09%
13 / 22
278.31
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 maybe_register_feed_hooks
0.00% covered (danger)
0.00%
0 / 24
0.00% covered (danger)
0.00%
0 / 1
20
 output_namespaces
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 feed_title
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
4.05
 feed_description
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 output_channel_tags
0.00% covered (danger)
0.00%
0 / 19
0.00% covered (danger)
0.00%
0 / 1
42
 output_item_tags
89.47% covered (warning)
89.47%
17 / 19
0.00% covered (danger)
0.00%
0 / 1
10.12
 skip_block_in_feed
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 capture_item_summary
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 rewrite_enclosure
96.88% covered (success)
96.88%
31 / 32
0.00% covered (danger)
0.00%
0 / 1
12
 reset_render_state
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 apply_feed_limit
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 constrain_feed_query
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 filter_posts_with_enclosure
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 is_podcast_feed_query
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
7
 explicit_string
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 show_image_url
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 build_stats_url
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 resolve_category_id
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
9
 episode_image_url
33.33% covered (danger)
33.33%
2 / 6
0.00% covered (danger)
0.00%
0 / 1
8.74
 maybe_photon
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 category_tag
100.00% covered (success)
100.00%
18 / 18
100.00% covered (success)
100.00%
1 / 1
3
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
9declare( strict_types = 1 );
10
11namespace Automattic\Jetpack\Podcast\Feed;
12
13use Automattic\Jetpack\Connection\Manager as Connection_Manager;
14use Automattic\Jetpack\Podcast\Settings;
15use 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 */
22class 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}